Compare commits

...

48 Commits

Author SHA1 Message Date
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
39 changed files with 914 additions and 366 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
lib
+12 -3
View File
@@ -17,15 +17,24 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - 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 - name: Build
run: make static run: make static || make static
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload artifact - name: Upload binary
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: mango name: mango
path: 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
+2 -3
View File
@@ -3,9 +3,8 @@ FROM crystallang/crystal:0.34.0-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . 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 libjpeg-turbo-dev libpng-dev tiff-dev
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \ RUN make static || make static
&& make static
FROM library/alpine FROM library/alpine
+14
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/ext/libwebp && git checkout v0.1.1 && make && cd ../stbi && make
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
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/ext/libwebp && git checkout v0.1.1 && make && cd ../stbi && make
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"]
+8 -2
View File
@@ -12,10 +12,10 @@ setup: libs
yarn gulp dev yarn gulp dev
build: libs build: libs
crystal build src/mango.cr --release --progress crystal build src/mango.cr --release --progress --error-trace
static: uglify | libs static: uglify | libs
crystal build src/mango.cr --release --progress --static crystal build src/mango.cr --release --progress --static --error-trace
libs: libs:
shards install --production shards install --production
@@ -31,6 +31,12 @@ check:
./bin/ameba ./bin/ameba
./dev/linewidth.sh ./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: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango
+7 -3
View File
@@ -13,7 +13,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader - 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 - The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless - All the static files are embedded in the binary, so the deployment process is easy and painless
@@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.9.0 Mango - Manga Server and Web Reader. Version 0.12.0
Usage: Usage:
@@ -139,8 +139,12 @@ Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png) ![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>
## Contributors ## 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) [![](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)
+30
View File
@@ -7,6 +7,7 @@
} }
.uk-card-media-top { .uk-card-media-top {
width: 100%;
height: 250px; height: 250px;
} }
@@ -122,3 +123,32 @@ td>.uk-dropdown {
.uk-light .uk-description-list>dt { .uk-light .uk-description-list>dt {
color: #555; 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);
}
+147 -70
View File
@@ -1,64 +1,91 @@
$(function() { $(() => {
function bind() { getPages();
var controller = new ScrollMagic.Controller();
// replace history on scroll $('#page-select').change(() => {
$('img').each(function(idx) { const p = parseInt($('#page-select').val());
var scene = new ScrollMagic.Scene({ toPage(p);
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);
});
});
// poor man's infinite scroll /**
var scene = new ScrollMagic.Scene({ * Set an alpine.js property
triggerElement: $('.next-url').get(), *
triggerHook: 'onEnter', * @function setProp
offset: -500 * @param {string} key - Key of the data property
}) * @param {*} prop - The data property
.addTo(controller) */
.on('enter', function() { const setProp = (key, prop) => {
var nextURL = $('.next-url').attr('href'); $('#root').get(0).__x.$data[key] = prop;
$('.next-url').remove(); };
if (!nextURL) {
console.log('No .next-url found. Reached end of page'); /**
var lastURL = $('img').last().attr('id'); * Get dimension of the pages in the entry from the API and update the view
// load the reader URL for the last page to update reading progrss to 100% */
$.get(lastURL); const getPages = () => {
$('#next-btn').removeAttr('hidden'); $.get(`${base_url}api/dimensions/${tid}/${eid}`)
return; .then(data => {
} if (!data.success && data.error)
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) { throw new Error(resp.error);
if (status === 'error') console.log(xhr.statusText); const dimensions = data.dimensions;
if (status === 'success') {
console.log(nextURL + ' loaded'); const items = dimensions.map((d, i) => {
// new page loaded to #hidden, we now append it return {
$('.uk-section > .uk-container').append($('#hidden .uk-container').children()); id: i + 1,
$('#hidden').empty(); url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
bind(); width: d.width,
} height: d.height
}); };
}); });
}
bind(); setProp('items', items);
}); setProp('loading', false);
$('#page-select').change(function() {
jumpTo(parseInt($('#page-select').val()));
});
function showControl(idx) { 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 pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText); $('#progress-label').text(progressText);
@@ -66,19 +93,69 @@ function showControl(idx) {
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
} }
function jumpTo(page) { /**
var ary = window.location.pathname.split('/'); * Redirect to a URL
ary[ary.length - 1] = page; *
ary.shift(); // remove leading `/` * @function redirect
ary.unshift(window.location.origin); * @param {string} url - The target URL
window.location.replace(ary.join('/')); */
} const redirect = (url) => {
function replaceHistory(url) {
history.replaceState(null, "", url);
console.log('reading ' + url);
}
function redirect(url) {
window.location.replace(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);
}
});
});
};
let lastSavedPage = page;
/**
* Update the backend reading progress if the current page is more than
* five pages away from the last saved page
*
* @function saveProgress
* @param {number} idx - One-based index of the page
*/
const saveProgress = (idx) => {
if (Math.abs(idx - lastSavedPage) < 5) return;
lastSavedPage = idx;
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`;
$.post(url)
.then(data => {
if (data.error) throw new Error(data.error);
})
.catch(e => {
console.error(e);
alert('danger', e);
});
};
+60
View File
@@ -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();
});
};
+8
View File
@@ -28,6 +28,14 @@ shards:
github: crystal-loot/exception_page github: crystal-loot/exception_page
version: 0.1.4 version: 0.1.4
http_proxy:
github: mamantoha/http_proxy
version: 0.7.1
image_size:
github: hkalexling/image_size.cr
version: 0.1.1
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: 0.26.1 version: 0.26.1
+5 -1
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.9.0 version: 0.12.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -32,3 +32,7 @@ dependencies:
version: ~> 0.20.0 version: ~> 0.20.0
myhtml: myhtml:
github: kostya/myhtml github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr
+1
View File
@@ -2,6 +2,7 @@ require "spec"
require "../src/queue" require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
+2 -6
View File
@@ -52,12 +52,8 @@ class Config
config.fill_defaults config.fill_defaults
return config return config
end end
puts "The config file #{cfg_path} does not exist." \ puts "The config file #{cfg_path} does not exist. " \
" Do you want mango to dump the default config there? [Y/n]" "Dumping the default config there."
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate default = self.allocate
default.path = path default.path = path
default.fill_defaults default.fill_defaults
+33 -5
View File
@@ -1,3 +1,5 @@
require "image_size"
class Entry class Entry
property zip_path : String, book : Title, title : String, property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String, size : String, pages : Int32, id : String, encoded_path : String,
@@ -77,11 +79,9 @@ class Entry
url url
end end
def read_page(page_num) private def sorted_archive_entries
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
ArchiveFile.open @zip_path do |file| ArchiveFile.open @zip_path do |file|
page = file.entries entries = file.entries
.select { |e| .select { |e|
SUPPORTED_IMG_TYPES.includes? \ SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
@@ -89,7 +89,15 @@ class Entry
.sort { |a, b| .sort { |a, b|
compare_numerically a.filename, b.filename 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 data = file.read_entry page
if data if data
img = Image.new data, MIME.from_filename(page.filename), page.filename, img = Image.new data, MIME.from_filename(page.filename), page.filename,
@@ -99,6 +107,26 @@ class Entry
img img
end 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) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
+50 -36
View File
@@ -30,6 +30,41 @@ class Library
@title_ids.map { |tid| self.get_title!(tid) } @title_ids.map { |tid| self.get_title!(tid) }
end 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 def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten titles + titles.map { |t| t.deep_titles }.flatten
end end
@@ -57,7 +92,6 @@ class Library
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
@title_ids.clear
storage = Storage.new auto_close: false storage = Storage.new auto_close: false
@@ -68,6 +102,7 @@ class Library
.map { |path| Title.new path, "", storage, self } .map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
@@ -83,7 +118,7 @@ class Library
cr_entries = deep_titles cr_entries = deep_titles
.map { |t| t.get_last_read_entry username } .map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s # 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| .map { |e|
# Get the last read time of the entry. If it hasn't been started, get # Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry # the last read time of the previous entry
@@ -143,41 +178,20 @@ class Library
end end
end end
recently_added[0..11] recently_added[0...ENTRIES_IN_HOME_SECTIONS]
end end
def sorted_titles(username, opt : SortOptions? = nil) def get_start_reading_titles(username)
if opt.nil? # Here we are not using `deep_titles` as it may cause unexpected behaviors
opt = SortOptions.from_info_json @dir, username # For example, consider the following nested titles:
else # - One Puch Man
TitleInfo.new @dir do |info| # - Vol. 1
info.sort_by[username] = opt.to_tuple # - Vol. 2
info.save # If we use `deep_titles`, the start reading section might include `Vol. 2`
end # when the user hasn't started `Vol. 1` yet
end titles
.select { |t| t.load_percentage(username) == 0 }
# This is a hack to bypass a compiler bug .sample(ENTRIES_IN_HOME_SECTIONS)
ary = titles .shuffle
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 end
end end
+20
View File
@@ -355,4 +355,24 @@ class Title
return zip if title_ids.empty? return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
end 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 end
+34
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
-1
View File
@@ -1,4 +1,3 @@
require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename" require "../rename"
+9 -7
View File
@@ -27,14 +27,16 @@ module MangaDex
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
DB.open "sqlite3://#{@queue.path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@queue.path}" do |db|
db.query_one "select * from queue where id not like '%-%' " \ begin
"and (status = 0 or status = 1) " \ db.query_one "select * from queue where id not like '%-%' " \
"order by time limit 1" do |res| "and (status = 0 or status = 1) " \
job = Queue::Job.from_query_result res "order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end end
rescue
end end
end end
job job
+31 -6
View File
@@ -1,12 +1,29 @@
require "./config" require "./config"
require "./queue" require "./queue"
require "./server" require "./server"
require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "./plugin/*"
MANGO_VERSION = "0.9.0" MANGO_VERSION = "0.12.0"
# From http://www.network-science.de/ascii/
BANNER = %{
_| _|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
}
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
macro common_option macro common_option
option "-c PATH", "--config=PATH", type: String, option "-c PATH", "--config=PATH", type: String,
@@ -22,20 +39,28 @@ end
class CLI < Clim class CLI < Clim
main do main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" desc DESCRIPTION
usage "mango [sub_command] [options]" usage "mango [sub_command] [options]"
help short: "-h" help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v" version "Version #{MANGO_VERSION}", short: "-v"
common_option common_option
run do |opts| 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 Config.load(opts.config).set_current
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
# empty ARGV so it won't be passed to Kemal spawn do
ARGV.clear Server.new.start
server = Server.new end
server.start
MainFiber.start_and_block
end end
sub "admin" do sub "admin" do
+9 -7
View File
@@ -8,14 +8,16 @@ class Plugin
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
DB.open "sqlite3://#{@queue.path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@queue.path}" do |db|
db.query_one "select * from queue where id like '%-%' " \ begin
"and (status = 0 or status = 1) " \ db.query_one "select * from queue where id like '%-%' " \
"order by time limit 1" do |res| "and (status = 0 or status = 1) " \
job = Queue::Job.from_query_result res "order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end end
rescue
end end
end end
job job
-1
View File
@@ -1,6 +1,5 @@
require "duktape/runtime" require "duktape/runtime"
require "myhtml" require "myhtml"
require "http"
require "xml" require "xml"
class Plugin class Plugin
+87 -59
View File
@@ -119,22 +119,24 @@ class Queue
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@path}" do |db|
db.exec "create table if not exists queue " \ begin
"(id text, manga_id text, title text, manga_title " \ db.exec "create table if not exists queue " \
"text, status integer, status_message text, " \ "(id text, manga_id text, title text, manga_title " \
"pages integer, success_count integer, " \ "text, status integer, status_message text, " \
"fail_count integer, time integer)" "pages integer, success_count integer, " \
db.exec "create unique index if not exists id_idx " \ "fail_count integer, time integer)"
"on queue (id)" db.exec "create unique index if not exists id_idx " \
db.exec "create index if not exists manga_id_idx " \ "on queue (id)"
"on queue (manga_id)" db.exec "create index if not exists manga_id_idx " \
db.exec "create index if not exists status_idx " \ "on queue (manga_id)"
"on queue (status)" db.exec "create index if not exists status_idx " \
rescue e "on queue (status)"
Logger.error "Error when checking tables in DB: #{e}" rescue e
raise e Logger.error "Error when checking tables in DB: #{e}"
raise e
end
end end
end end
end end
@@ -143,23 +145,27 @@ class Queue
# inserted. Any job already exists in the queue will be ignored. # inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job)) def push(jobs : Array(Job))
start_count = self.count start_count = self.count
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
jobs.each do |job| DB.open "sqlite3://#{@path}" do |db|
db.exec "insert or ignore into queue values " \ 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.id, job.manga_id, job.title, job.manga_title,
job.success_count, job.fail_count, job.time.to_unix_ms job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
end
end end
end end
self.count - start_count self.count - start_count
end end
def reset(id : String) def reset(id : String)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = 0, status_message = '', " \ DB.open "sqlite3://#{@path}" do |db|
"pages = 0, success_count = 0, fail_count = 0 " \ db.exec "update queue set status = 0, status_message = '', " \
"where id = (?)", id "pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
end
end end
end end
@@ -169,16 +175,20 @@ class Queue
# Reset all failed tasks (missing pages and error) # Reset all failed tasks (missing pages and error)
def reset def reset
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = 0, status_message = '', " \ DB.open "sqlite3://#{@path}" do |db|
"pages = 0, success_count = 0, fail_count = 0 " \ db.exec "update queue set status = 0, status_message = '', " \
"where status = 2 or status = 4" "pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end end
end end
def delete(id : String) def delete(id : String)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "delete from queue where id = (?)", id DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end end
end end
@@ -187,71 +197,89 @@ class Queue
end end
def delete_status(status : JobStatus) def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "delete from queue where status = (?)", status.to_i DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
end
end end
end end
def count_status(status : JobStatus) def count_status(status : JobStatus)
num = 0 num = 0
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
num = db.query_one "select count(*) from queue where " \ DB.open "sqlite3://#{@path}" do |db|
"status = (?)", status.to_i, as: Int32 num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
end
end end
num num
end end
def count def count
num = 0 num = 0
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
num = db.query_one "select count(*) from queue", as: Int32 DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
end
end end
num num
end end
def set_status(status : JobStatus, job : Job) def set_status(status : JobStatus, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = (?) where id = (?)", DB.open "sqlite3://#{@path}" do |db|
status.to_i, job.id db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
end
end end
end end
def get_all def get_all
jobs = [] of Job jobs = [] of Job
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
jobs = db.query_all "select * from queue order by time" do |rs| DB.open "sqlite3://#{@path}" do |db|
Job.from_query_result rs jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs
end
end end
end end
jobs jobs
end end
def add_success(job : Job) def add_success(job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set success_count = success_count + 1 " \ DB.open "sqlite3://#{@path}" do |db|
"where id = (?)", job.id db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
end
end end
end end
def add_fail(job : Job) def add_fail(job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set fail_count = fail_count + 1 " \ DB.open "sqlite3://#{@path}" do |db|
"where id = (?)", job.id db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
end
end end
end end
def set_pages(pages : Int32, job : Job) def set_pages(pages : Int32, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set pages = (?), success_count = 0, " \ DB.open "sqlite3://#{@path}" do |db|
"fail_count = 0 where id = (?)", pages, job.id db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id
end
end end
end end
def add_message(msg : String, job : Job) def add_message(msg : String, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status_message = " \ DB.open "sqlite3://#{@path}" do |db|
"status_message || (?) || (?) where id = (?)", db.exec "update queue set status_message = " \
"\n", msg, job.id "status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
end end
end end
+45
View File
@@ -97,6 +97,28 @@ class APIRouter < Router
end end
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| post "/api/admin/display_name/:title/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"])
@@ -310,5 +332,28 @@ class APIRouter < Router
}.to_json }.to_json
end end
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
end end
+1
View File
@@ -103,6 +103,7 @@ class MainRouter < Router
continue_reading = @context continue_reading = @context
.library.get_continue_reading_entries username .library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username recently_added = @context.library.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username
titles = @context.library.titles titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0 empty_library = titles.size == 0
+4 -20
View File
@@ -13,10 +13,6 @@ class ReaderRouter < Router
# load progress # load progress
page = entry.load_progress username 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
# start from page 1 if the user has finished reading the entry # start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username page = 1 if entry.finished? username
@@ -32,29 +28,17 @@ class ReaderRouter < Router
begin begin
base_url = Config.current.base_url base_url = Config.current.base_url
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 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}" exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username 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? unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end end
+122 -94
View File
@@ -32,38 +32,40 @@ class Storage
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@path}" do |db|
# We create the `ids` table first. even if the uses has an begin
# early version installed and has the `user` table only, # We create the `ids` table first. even if the uses has an
# we will still be able to create `ids` # early version installed and has the `user` table only,
db.exec "create table ids" \ # we will still be able to create `ids`
"(path text, id text, is_title integer)" db.exec "create table ids" \
db.exec "create unique index path_idx on ids (path)" "(path text, id text, is_title integer)"
db.exec "create unique index id_idx on ids (id)" 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" \ db.exec "create table users" \
"(username text, password text, token text, admin integer)" "(username text, password text, token text, admin integer)"
rescue e rescue e
unless e.message.not_nil!.ends_with? "already exists" unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}" Logger.fatal "Error when checking tables in DB: #{e}"
raise 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 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
end unless @auto_close
unless @auto_close @db = DB.open "sqlite3://#{@path}"
@db = DB.open "sqlite3://#{@path}" end
end end
end end
@@ -87,37 +89,45 @@ class Storage
end end
def verify_user(username, password) def verify_user(username, password)
get_db do |db| out_token = nil
begin MainFiber.run do
hash, token = db.query_one "select password, token from " \ get_db do |db|
"users where username = (?)", begin
username, as: {String, String?} hash, token = db.query_one "select password, token from " \
unless verify_password hash, password "users where username = (?)",
Logger.debug "Password does not match the hash" username, as: {String, String?}
return nil 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 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
end end
out_token
end end
def verify_token(token) def verify_token(token)
username = nil username = nil
get_db do |db| MainFiber.run do
begin get_db do |db|
username = db.query_one "select username from users where " \ begin
"token = (?)", token, as: String username = db.query_one "select username from users where " \
rescue e "token = (?)", token, as: String
Logger.debug "Unable to verify token" rescue e
Logger.debug "Unable to verify token"
end
end end
end end
username username
@@ -125,12 +135,14 @@ class Storage
def verify_admin(token) def verify_admin(token)
is_admin = false is_admin = false
get_db do |db| MainFiber.run do
begin get_db do |db|
is_admin = db.query_one "select admin from users where " \ begin
"token = (?)", token, as: Bool is_admin = db.query_one "select admin from users where " \
rescue e "token = (?)", token, as: Bool
Logger.debug "Unable to verify user as admin" rescue e
Logger.debug "Unable to verify user as admin"
end
end end
end end
is_admin is_admin
@@ -138,10 +150,12 @@ class Storage
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
get_db do |db| MainFiber.run do
db.query "select username, admin from users" do |rs| get_db do |db|
rs.each do db.query "select username, admin from users" do |rs|
results << {rs.read(String), rs.read(Bool)} rs.each do
results << {rs.read(String), rs.read(Bool)}
end
end end
end end
end end
@@ -152,10 +166,12 @@ class Storage
validate_username username validate_username username
validate_password password validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
get_db do |db| MainFiber.run do
hash = hash_password password get_db do |db|
db.exec "insert into users values (?, ?, ?, ?)", hash = hash_password password
username, hash, nil, admin db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
end
end end
end end
@@ -163,40 +179,48 @@ class Storage
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username validate_username username
validate_password password unless password.empty? validate_password password unless password.empty?
get_db do |db| MainFiber.run do
if password.empty? get_db do |db|
db.exec "update users set username = (?), admin = (?) " \ if password.empty?
"where username = (?)", db.exec "update users set username = (?), admin = (?) " \
username, admin, original_username "where username = (?)",
else username, admin, original_username
hash = hash_password password else
db.exec "update users set username = (?), admin = (?)," \ hash = hash_password password
"password = (?) where username = (?)", db.exec "update users set username = (?), admin = (?)," \
username, admin, hash, original_username "password = (?) where username = (?)",
username, admin, hash, original_username
end
end end
end end
end end
def delete_user(username) def delete_user(username)
get_db do |db| MainFiber.run do
db.exec "delete from users where username = (?)", username get_db do |db|
db.exec "delete from users where username = (?)", username
end
end end
end end
def logout(token) def logout(token)
get_db do |db| MainFiber.run do
begin get_db do |db|
db.exec "update users set token = (?) where token = (?)", nil, token begin
rescue db.exec "update users set token = (?) where token = (?)", nil, token
rescue
end
end end
end end
end end
def get_id(path, is_title) def get_id(path, is_title)
id = nil id = nil
get_db do |db| MainFiber.run do
id = db.query_one? "select id from ids where path = (?)", path, get_db do |db|
as: {String} id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
end
end end
id id
end end
@@ -206,20 +230,24 @@ class Storage
end end
def bulk_insert_ids def bulk_insert_ids
get_db do |db| MainFiber.run do
db.transaction do |tx| get_db do |db|
@insert_ids.each do |tp| db.transaction do |tx|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path], @insert_ids.each do |tp|
tp[:id], tp[:is_title] ? 1 : 0 tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
end
end end
end end
@insert_ids.clear
end end
@insert_ids.clear
end end
def close def close
unless @db.nil? MainFiber.run do
@db.not_nil!.close unless @db.nil?
@db.not_nil!.close
end
end end
end end
+42
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
+4 -3
View File
@@ -1,6 +1,7 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads" ENTRIES_IN_HOME_SECTIONS = 8
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"] UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
+3 -3
View File
@@ -2,9 +2,9 @@ def validate_username(username)
if username.size < 3 if username.size < 3
raise "Username should contain at least 3 characters" raise "Username should contain at least 3 characters"
end end
if (username =~ /^[A-Za-z0-9_]+$/).nil? if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
raise "Username should contain alphanumeric characters " \ raise "Username can only contain alphanumeric characters, " \
"and underscores only" "underscores, and hyphens"
end end
end end
+11 -3
View File
@@ -35,12 +35,20 @@
onclick="location='<%= base_url %>book/<%= item.id %>'" onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>> <% end %>>
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
<div class="uk-card-media-top"> <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img 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 %> <% if item.is_a? Entry && item.err_msg %>
class="grayscale" class="grayscale"
<% end %>> <% 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>
<div class="uk-card-body"> <div class="uk-card-body">
+1
View File
@@ -10,5 +10,6 @@
<script defer src="<%= base_url %>js/fontawesome.min.js"></script> <script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.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 src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js" defer></script>
<script src="<%= base_url %>js/theme.js"></script> <script src="<%= base_url %>js/theme.js"></script>
</head> </head>
+13 -2
View File
@@ -41,7 +41,7 @@
<%- unless continue_reading.empty? -%> <%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2> <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| -%> <%- continue_reading.each do |cr| -%>
<% item = cr[:entry] %> <% item = cr[:entry] %>
<% progress = cr[:percentage] %> <% progress = cr[:percentage] %>
@@ -50,9 +50,20 @@
</div> </div>
<%- end -%> <%- 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? -%> <%- unless recently_added.empty? -%>
<h2 class="uk-title home-headings">Recently Added</h2> <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| -%> <%- recently_added.each do |ra| -%>
<% item = ra %> <% item = ra %>
<% progress = ra[:percentage] %> <% progress = ra[:percentage] %>
+4 -1
View File
@@ -67,10 +67,13 @@
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small">
</div> </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 class="uk-container uk-container-small">
<div id="alert"></div> <div id="alert"></div>
<%= content %> <%= 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>
</div> </div>
<script> <script>
+1 -1
View File
@@ -16,7 +16,7 @@
<%= render_component "sort-form" %> <%= render_component "sort-form" %>
</div> </div>
</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| %> <% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %> <% progress = percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
+12 -12
View File
@@ -21,18 +21,18 @@
<% end %> <% end %>
<% title.entries.each do |e| %> <% title.entries.each do |e| %>
<entry> <% next if e.err_msg %>
<title><%= HTML.escape(e.display_name) %></title> <entry>
<id>urn:mango:<%= e.id %></id> <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" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>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="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
</entry> </entry>
<% end %> <% end %>
</feed>
</feed>
+43 -16
View File
@@ -6,22 +6,40 @@
<body> <body>
<div class="uk-section uk-section-default uk-section-small reader-bg"> <div class="uk-section uk-section-default uk-section-small reader-bg">
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%> <div id="alert"></div>
<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] %>);"> <div id="root" x-data="{
<%- end -%> loading: true,
<%- if next_url -%> msg: 'Loading the web reader. Please wait...',
<a class="next-url" href="<%= next_url %>"></a> alertClass: 'uk-alert-primary',
<%- end -%> 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="redirect('<%= 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> </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>
<div id="hidden" hidden></div>
<div id="modal-sections" class="uk-flex-top" uk-modal> <div id="modal-sections" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
@@ -49,14 +67,23 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>" const base_url = "<%= base_url %>";
const page = <%= page %>;
const tid = "<%= title.id %>";
const eid = "<%= entry.id %>";
</script> </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/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.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.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script> <script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script> <script src="<%= base_url %>js/reader.js"></script>
</body> </body>
<style>
img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; }
</style>
</html> </html>
+23 -1
View File
@@ -1,4 +1,23 @@
<div> <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> <h2 class=uk-title><span><%= title.display_name %></span>
&nbsp; &nbsp;
<% if is_admin %> <% if is_admin %>
@@ -32,11 +51,14 @@
<%= render_component "sort-form" %> <%= render_component "sort-form" %>
</div> </div>
</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| %> <% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %> <% progress = title_percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
<% end %> <% end %>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% entries.each_with_index do |item, i| %> <% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %> <% progress = percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>