mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec257c99f | ||
|
|
f2df493d79 | ||
|
|
b74f61c025 | ||
|
|
c76c287e66 | ||
|
|
8e7eaa680a | ||
|
|
30cdb3ec8f | ||
|
|
9c367e7d35 | ||
|
|
d2f95e5970 | ||
|
|
82bcd03f15 | ||
|
|
fe799f30c8 | ||
|
|
54123917af | ||
|
|
3b737c0bee | ||
|
|
14bf4da06c | ||
|
|
a72dfcecd3 | ||
|
|
160a249dc6 | ||
|
|
f9a2534f80 | ||
|
|
06fe2ccf16 | ||
|
|
e214e00dfb | ||
|
|
9b5aea223d | ||
|
|
1b9d83f367 | ||
|
|
96b8186add | ||
|
|
27dab3c989 | ||
|
|
bcb95d1462 | ||
|
|
4371c7877d | ||
|
|
d72d635c68 | ||
|
|
b724b4d508 | ||
|
|
8bbbe650f1 | ||
|
|
651bd17612 | ||
|
|
dd01e632a2 | ||
|
|
43ee8f3b85 | ||
|
|
bedcac4e35 | ||
|
|
5260a82e88 | ||
|
|
1efb300988 | ||
|
|
6b43ee7fe5 | ||
|
|
3039031924 | ||
|
|
8665616c2e | ||
|
|
4453b0ee9f | ||
|
|
487154e68c | ||
|
|
60609263ab | ||
|
|
4a245d2504 | ||
|
|
48c3a82078 | ||
|
|
4a59459773 | ||
|
|
eefa8c3982 | ||
|
|
8fe2f3b4cc | ||
|
|
60d4cee0a9 | ||
|
|
8658cb8306 | ||
|
|
d4e523c337 | ||
|
|
d49c0092c2 | ||
|
|
d75009f088 | ||
|
|
d416dc6618 | ||
|
|
7233e6e5c3 | ||
|
|
bd8ae9497f | ||
|
|
34b11dc2c7 | ||
|
|
30dea57346 | ||
|
|
7448592216 | ||
|
|
049bd3ab2c | ||
|
|
c3608c101b | ||
|
|
1bec9f0108 | ||
|
|
09b297cd8e | ||
|
|
b7cd55e692 | ||
|
|
986939ecb6 | ||
|
|
a5e97af3a3 | ||
|
|
4cee5faecd | ||
|
|
711add74ef | ||
|
|
f6f09c54bc | ||
|
|
0f58ebb87b | ||
|
|
46347a8fe4 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: hkalexling
|
||||
ko_fi: hkalexling
|
||||
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -8,19 +8,24 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: crystallang/crystal:0.34.0-alpine
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: apk add --no-cache yarn yaml sqlite-static
|
||||
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
|
||||
- name: Build
|
||||
run: make
|
||||
run: make static
|
||||
- name: Linter
|
||||
run: make check
|
||||
- name: Run tests
|
||||
run: make test
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: mango
|
||||
path: mango
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ yarn.lock
|
||||
dist
|
||||
mango
|
||||
.env
|
||||
*.md
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /Mango
|
||||
|
||||
COPY . .
|
||||
COPY package*.json .
|
||||
RUN apk add --no-cache yarn yaml sqlite-static \
|
||||
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
|
||||
|
||||
FROM library/alpine
|
||||
|
||||
3
Makefile
3
Makefile
@@ -14,7 +14,7 @@ static: uglify | libs
|
||||
crystal build src/mango.cr --release --progress --static
|
||||
|
||||
libs:
|
||||
shards install
|
||||
shards install --production
|
||||
|
||||
run:
|
||||
crystal run src/mango.cr --error-trace
|
||||
@@ -25,6 +25,7 @@ test:
|
||||
check:
|
||||
crystal tool format --check
|
||||
./bin/ameba
|
||||
./dev/linewidth.sh
|
||||
|
||||
install:
|
||||
cp mango $(INSTALL_DIR)/mango
|
||||
|
||||
43
README.md
43
README.md
@@ -11,7 +11,7 @@ Mango is a self-hosted manga server and reader. Its features include
|
||||
|
||||
- Multi-user support
|
||||
- Dark/light mode switch
|
||||
- Supports both `.zip` and `.cbz` formats
|
||||
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
||||
- Supports nested folders in library
|
||||
- Automatically stores reading progress
|
||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||
@@ -35,11 +35,11 @@ Simply download the pre-built binary file `mango` for the latest [release](https
|
||||
|
||||
### Docker (via Dockerhub)
|
||||
|
||||
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
|
||||
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
|
||||
|
||||
### Build from source
|
||||
|
||||
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
|
||||
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
|
||||
2. Clone the repository
|
||||
3. `make && sudo make install`
|
||||
4. Start Mango by running the command `mango`
|
||||
@@ -50,11 +50,21 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango e-manga server/reader. Version 0.3.0
|
||||
Mango - Manga Server and Web Reader. Version 0.5.2
|
||||
|
||||
-v, --version Show version
|
||||
-h, --help Show help
|
||||
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
|
||||
Usage:
|
||||
|
||||
mango [sub_command] [options]
|
||||
|
||||
Options:
|
||||
|
||||
-c PATH, --config=PATH Path to the config file [type:String]
|
||||
-h, --help Show this help.
|
||||
-v, --version Show version.
|
||||
|
||||
Sub Commands:
|
||||
|
||||
admin Run admin tools
|
||||
```
|
||||
|
||||
### Config
|
||||
@@ -64,17 +74,20 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
||||
```yaml
|
||||
---
|
||||
port: 9000
|
||||
library_path: /home/alex_ling/mango/library
|
||||
upload_path: /home/alex_ling/mango/uploads
|
||||
db_path: /home/alex_ling/mango/mango.db
|
||||
base_url: /
|
||||
library_path: ~/mango/library
|
||||
db_path: ~/mango/mango.db
|
||||
scan_interval_minutes: 5
|
||||
log_level: info
|
||||
upload_path: ~/mango/uploads
|
||||
mangadex:
|
||||
base_url: https://mangadex.org
|
||||
api_url: https://mangadex.org/api
|
||||
download_wait_seconds: 5
|
||||
download_retries: 4
|
||||
download_queue_db_path: /home/alex_ling/mango/queue.db
|
||||
download_queue_db_path: ~/mango/queue.db
|
||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||
manga_rename_rule: '{title}'
|
||||
```
|
||||
|
||||
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
||||
@@ -82,7 +95,7 @@ mangadex:
|
||||
|
||||
### Library Structure
|
||||
|
||||
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
|
||||
You can organize your archive files in nested folders in the library directory. Here's an example:
|
||||
|
||||
```
|
||||
.
|
||||
@@ -94,8 +107,8 @@ You can organize your `.cbz/.zip` files in nested folders in the library directo
|
||||
└── Manga 2
|
||||
  └── Vol. 1
|
||||
  └── Ch.1 - Ch.3
|
||||
  ├── 1.zip
|
||||
  ├── 2.zip
|
||||
  ├── 1.zip
|
||||
  ├── 2.zip
|
||||
  └── 3.zip
|
||||
```
|
||||
|
||||
@@ -127,4 +140,6 @@ Mobile UI:
|
||||
|
||||
## Contributors
|
||||
|
||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
|
||||
|
||||
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
||||
|
||||
5
dev/linewidth.sh
Executable file
5
dev/linewidth.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \
|
||||
&& echo "The above lines exceed the 80 characters limit" \
|
||||
|| exit 0
|
||||
@@ -36,7 +36,8 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.uk-logo > img {
|
||||
max-height: 90px;
|
||||
height: 90px;
|
||||
width: 90px;
|
||||
}
|
||||
.uk-search {
|
||||
width: 100%;
|
||||
|
||||
@@ -5,7 +5,7 @@ function scan() {
|
||||
$('#scan-status > span').attr('hidden', '');
|
||||
var color = $('#scan').css('color');
|
||||
$('#scan').css('color', 'gray');
|
||||
$.post('/api/admin/scan', function (data) {
|
||||
$.post(base_url + 'api/admin/scan', function (data) {
|
||||
var ms = data.milliseconds;
|
||||
var titles = data.titles;
|
||||
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
||||
|
||||
@@ -22,7 +22,7 @@ const loadConfig = () => {
|
||||
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
||||
};
|
||||
const remove = (id) => {
|
||||
var url = '/api/admin/mangadex/queue/delete';
|
||||
var url = base_url + 'api/admin/mangadex/queue/delete';
|
||||
if (id !== undefined)
|
||||
url += '?' + $.param({id: id});
|
||||
console.log(url);
|
||||
@@ -43,7 +43,7 @@ const remove = (id) => {
|
||||
});
|
||||
};
|
||||
const refresh = (id) => {
|
||||
var url = '/api/admin/mangadex/queue/retry';
|
||||
var url = base_url + 'api/admin/mangadex/queue/retry';
|
||||
if (id !== undefined)
|
||||
url += '?' + $.param({id: id});
|
||||
console.log(url);
|
||||
@@ -67,7 +67,7 @@ const toggle = () => {
|
||||
$('#pause-resume-btn').attr('disabled', '');
|
||||
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
||||
const action = paused ? 'resume' : 'pause';
|
||||
const url = `/api/admin/mangadex/queue/${action}`;
|
||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
@@ -87,7 +87,7 @@ const load = () => {
|
||||
console.log('fetching');
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: '/api/admin/mangadex/queue',
|
||||
url: base_url + 'api/admin/mangadex/queue',
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
|
||||
@@ -33,7 +33,7 @@ const download = () => {
|
||||
console.log(ids);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/api/admin/mangadex/download',
|
||||
url: base_url + 'api/admin/mangadex/download',
|
||||
data: JSON.stringify({chapters: chapters}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
@@ -47,7 +47,7 @@ const download = () => {
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = '/admin/downloads';
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
});
|
||||
styleModal();
|
||||
})
|
||||
@@ -109,7 +109,7 @@ const search = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
$.getJSON("/api/admin/mangadex/manga/" + int_id)
|
||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||
@@ -242,7 +242,10 @@ const buildTable = () => {
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v === 'All') return;
|
||||
if (k === 'group') {
|
||||
chapters = chapters.filter(c => v in c.groups);
|
||||
chapters = chapters.filter(c => {
|
||||
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
||||
return unescaped_groups.indexOf(v) >= 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (k === 'lang') {
|
||||
@@ -297,3 +300,9 @@ const buildTable = () => {
|
||||
});
|
||||
$('#selection-controls').removeAttr('hidden');
|
||||
};
|
||||
|
||||
const unescapeHTML = (str) => {
|
||||
var elt = document.createElement("span");
|
||||
elt.innerHTML = str;
|
||||
return elt.innerText;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
$('#path-text').text(zipPath);
|
||||
$('#pages-text').text(pages + ' pages');
|
||||
|
||||
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
|
||||
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
|
||||
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
|
||||
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
|
||||
|
||||
$('#read-btn').click(function(){
|
||||
updateProgress(titleID, entryID, pages);
|
||||
@@ -39,7 +39,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
}
|
||||
|
||||
const updateProgress = (tid, eid, page) => {
|
||||
let url = `/api/progress/${tid}/${page}`
|
||||
let url = `${base_url}api/progress/${tid}/${page}`
|
||||
const query = $.param({entry: eid});
|
||||
if (eid)
|
||||
url += `?${query}`;
|
||||
@@ -66,7 +66,7 @@ const renameSubmit = (name, eid) => {
|
||||
}
|
||||
|
||||
const query = $.param({ entry: eid });
|
||||
let url = `/api/admin/display_name/${titleId}/${name}`;
|
||||
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||
if (eid)
|
||||
url += `?${query}`;
|
||||
|
||||
@@ -130,7 +130,7 @@ const setupUpload = (eid) => {
|
||||
if (eid)
|
||||
queryObj['entry'] = eid;
|
||||
const query = $.param(queryObj);
|
||||
const url = `/api/admin/upload/cover?${query}`;
|
||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||
console.log(url);
|
||||
UIkit.upload('.upload-field', {
|
||||
url: url,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
$(() => {
|
||||
var target = '/admin/user/edit';
|
||||
var target = base_url + 'admin/user/edit';
|
||||
if (username) target += username;
|
||||
$('form').attr('action', target);
|
||||
if (error) alert('danger', error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function remove(username) {
|
||||
$.post('/api/admin/user/delete/' + username, function(data) {
|
||||
$.post(base_url + 'api/admin/user/delete/' + username, function(data) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
10
shard.lock
10
shard.lock
@@ -2,12 +2,20 @@ version: 1.0
|
||||
shards:
|
||||
ameba:
|
||||
github: crystal-ameba/ameba
|
||||
version: 0.12.0
|
||||
version: 0.12.1
|
||||
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
version: 0.2.0
|
||||
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
version: 0.9.8
|
||||
|
||||
clim:
|
||||
github: at-grandpa/clim
|
||||
version: 0.12.0
|
||||
|
||||
db:
|
||||
github: crystal-lang/crystal-db
|
||||
version: 0.9.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.3.0
|
||||
version: 0.5.2
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -19,7 +19,9 @@ dependencies:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
|
||||
development_dependencies:
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
ameba:
|
||||
github: crystal-ameba/ameba
|
||||
clim:
|
||||
github: at-grandpa/clim
|
||||
|
||||
@@ -2,7 +2,7 @@ require "./spec_helper"
|
||||
|
||||
describe Config do
|
||||
it "creates config if it does not exist" do
|
||||
with_default_config do |_, _, path|
|
||||
with_default_config do |_, path|
|
||||
File.exists?(path).should be_true
|
||||
end
|
||||
end
|
||||
|
||||
71
spec/rename_spec.cr
Normal file
71
spec/rename_spec.cr
Normal file
@@ -0,0 +1,71 @@
|
||||
require "./spec_helper"
|
||||
require "../src/rename"
|
||||
|
||||
include Rename
|
||||
|
||||
describe Rule do
|
||||
it "raises on nested brackets" do
|
||||
expect_raises Exception do
|
||||
Rule.new "[[]]"
|
||||
end
|
||||
expect_raises Exception do
|
||||
Rule.new "{{}}"
|
||||
end
|
||||
end
|
||||
|
||||
it "raises on unclosed brackets" do
|
||||
expect_raises Exception do
|
||||
Rule.new "["
|
||||
end
|
||||
expect_raises Exception do
|
||||
Rule.new "{"
|
||||
end
|
||||
expect_raises Exception do
|
||||
Rule.new "[{]}"
|
||||
end
|
||||
end
|
||||
|
||||
it "raises when closing unopened brackets" do
|
||||
expect_raises Exception do
|
||||
Rule.new "]"
|
||||
end
|
||||
expect_raises Exception do
|
||||
Rule.new "[}"
|
||||
end
|
||||
end
|
||||
|
||||
it "handles `|` in patterns" do
|
||||
rule = Rule.new "{a|b|c}"
|
||||
rule.render({"b" => "b"}).should eq "b"
|
||||
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
||||
end
|
||||
|
||||
it "allows `|` outside of patterns" do
|
||||
rule = Rule.new "hello|world"
|
||||
rule.render({} of String => String).should eq "hello|world"
|
||||
end
|
||||
|
||||
it "raises on escaped characters" do
|
||||
expect_raises Exception do
|
||||
Rule.new "hello/world"
|
||||
end
|
||||
end
|
||||
|
||||
it "handles spaces in patterns" do
|
||||
rule = Rule.new "{ a }"
|
||||
rule.render({"a" => "a"}).should eq "a"
|
||||
end
|
||||
|
||||
it "strips leading and tailing spaces" do
|
||||
rule = Rule.new " hello "
|
||||
rule.render({"a" => "a"}).should eq "hello"
|
||||
end
|
||||
|
||||
it "renders a few examples correctly" do
|
||||
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
|
||||
rule.render({"id" => "ID"}).should eq "ID testing"
|
||||
rule.render({"chapter" => "CH", "id" => "ID"})
|
||||
.should eq "Ch. CH ID testing"
|
||||
rule.render({} of String => String).should eq "testing"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
require "spec"
|
||||
require "../src/context"
|
||||
require "../src/server"
|
||||
require "../src/config"
|
||||
|
||||
class State
|
||||
@@hash = {} of String => String
|
||||
@@ -37,15 +37,15 @@ end
|
||||
def with_default_config
|
||||
temp_config = get_tempfile "mango-test-config"
|
||||
config = Config.load temp_config.path
|
||||
logger = Logger.new config.log_level
|
||||
yield config, logger, temp_config.path
|
||||
config.set_current
|
||||
yield config, temp_config.path
|
||||
temp_config.delete
|
||||
end
|
||||
|
||||
def with_storage
|
||||
with_default_config do |_, logger|
|
||||
with_default_config do
|
||||
temp_db = get_tempfile "mango-test-db"
|
||||
storage = Storage.new temp_db.path, logger
|
||||
storage = Storage.new temp_db.path, false
|
||||
clear = yield storage, temp_db.path
|
||||
if clear == true
|
||||
temp_db.delete
|
||||
@@ -54,9 +54,9 @@ def with_storage
|
||||
end
|
||||
|
||||
def with_queue
|
||||
with_default_config do |_, logger|
|
||||
with_default_config do
|
||||
temp_queue_db = get_tempfile "mango-test-queue-db"
|
||||
queue = MangaDex::Queue.new temp_queue_db.path, logger
|
||||
queue = MangaDex::Queue.new temp_queue_db.path
|
||||
clear = yield queue, temp_queue_db.path
|
||||
if clear == true
|
||||
temp_queue_db.delete
|
||||
|
||||
59
src/archive.cr
Normal file
59
src/archive.cr
Normal file
@@ -0,0 +1,59 @@
|
||||
require "zip"
|
||||
require "archive"
|
||||
|
||||
# A unified class to handle all supported archive formats. It uses the ::Zip
|
||||
# module in crystal standard library if the target file is a zip archive.
|
||||
# Otherwise it uses `archive.cr`.
|
||||
class ArchiveFile
|
||||
def initialize(@filename : String)
|
||||
if [".cbz", ".zip"].includes? File.extname filename
|
||||
@archive_file = Zip::File.new filename
|
||||
else
|
||||
@archive_file = Archive::File.new filename
|
||||
end
|
||||
end
|
||||
|
||||
def self.open(filename : String, &)
|
||||
s = self.new filename
|
||||
yield s
|
||||
s.close
|
||||
end
|
||||
|
||||
def close
|
||||
if @archive_file.is_a? Zip::File
|
||||
@archive_file.as(Zip::File).close
|
||||
end
|
||||
end
|
||||
|
||||
# Lists all file entries
|
||||
def entries
|
||||
ary = [] of Zip::File::Entry | Archive::Entry
|
||||
@archive_file.entries.map do |e|
|
||||
if (e.is_a? Zip::File::Entry && e.file?) ||
|
||||
(e.is_a? Archive::Entry && e.info.file?)
|
||||
ary.push e
|
||||
end
|
||||
end
|
||||
ary
|
||||
end
|
||||
|
||||
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
|
||||
if e.is_a? Zip::File::Entry
|
||||
data = nil
|
||||
e.open do |io|
|
||||
slice = Bytes.new e.uncompressed_size
|
||||
bytes_read = io.read_fully? slice
|
||||
data = slice if bytes_read
|
||||
end
|
||||
data
|
||||
else
|
||||
e.read
|
||||
end
|
||||
end
|
||||
|
||||
def check
|
||||
if @archive_file.is_a? Archive::File
|
||||
@archive_file.as(Archive::File).check
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ class Config
|
||||
include YAML::Serializable
|
||||
|
||||
property port : Int32 = 9000
|
||||
property base_url : String = "/"
|
||||
property library_path : String = File.expand_path "~/mango/library",
|
||||
home: true
|
||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||
@@ -22,13 +23,26 @@ class Config
|
||||
"download_retries" => 4,
|
||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||
home: true),
|
||||
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
||||
"manga_rename_rule" => "{title}",
|
||||
}
|
||||
|
||||
@@singlet : Config?
|
||||
|
||||
def self.current
|
||||
@@singlet.not_nil!
|
||||
end
|
||||
|
||||
def set_current
|
||||
@@singlet = self
|
||||
end
|
||||
|
||||
def self.load(path : String?)
|
||||
path = "~/.config/mango/config.yml" if path.nil?
|
||||
cfg_path = File.expand_path path, home: true
|
||||
if File.exists? cfg_path
|
||||
config = self.from_yaml File.read cfg_path
|
||||
config.preprocess
|
||||
config.fill_defaults
|
||||
return config
|
||||
end
|
||||
@@ -58,4 +72,13 @@ class Config
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def preprocess
|
||||
unless base_url.starts_with? "/"
|
||||
raise "base url (#{base_url}) should start with `/`"
|
||||
end
|
||||
unless base_url.ends_with? "/"
|
||||
@base_url += "/"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
require "./config"
|
||||
require "./library"
|
||||
require "./storage"
|
||||
require "./logger"
|
||||
|
||||
class Context
|
||||
property config : Config
|
||||
property library : Library
|
||||
property storage : Storage
|
||||
property logger : Logger
|
||||
property queue : MangaDex::Queue
|
||||
|
||||
def initialize(@config, @logger, @library, @storage, @queue)
|
||||
end
|
||||
|
||||
{% for lvl in Logger::LEVELS %}
|
||||
def {{lvl.id}}(msg)
|
||||
@logger.{{lvl.id}} msg
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
@@ -9,9 +9,11 @@ class AuthHandler < Kemal::Handler
|
||||
def call(env)
|
||||
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
|
||||
|
||||
cookie = env.request.cookies.find { |c| c.name == "token" }
|
||||
cookie = env.request.cookies.find do |c|
|
||||
c.name == "token-#{Config.current.port}"
|
||||
end
|
||||
if cookie.nil? || !@storage.verify_token cookie.value
|
||||
return env.redirect "/login"
|
||||
return redirect env, "/login"
|
||||
end
|
||||
|
||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||
|
||||
@@ -2,20 +2,17 @@ require "kemal"
|
||||
require "../logger"
|
||||
|
||||
class LogHandler < Kemal::BaseLogHandler
|
||||
def initialize(@logger : Logger)
|
||||
end
|
||||
|
||||
def call(env)
|
||||
elapsed_time = Time.measure { call_next env }
|
||||
elapsed_text = elapsed_text elapsed_time
|
||||
msg = "#{env.response.status_code} #{env.request.method}" \
|
||||
" #{env.request.resource} #{elapsed_text}"
|
||||
@logger.debug msg
|
||||
Logger.debug msg
|
||||
env
|
||||
end
|
||||
|
||||
def write(msg)
|
||||
@logger.debug msg
|
||||
Logger.debug msg
|
||||
end
|
||||
|
||||
private def elapsed_text(elapsed)
|
||||
|
||||
@@ -11,7 +11,9 @@ class UploadHandler < Kemal::Handler
|
||||
return call_next env
|
||||
end
|
||||
|
||||
ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
|
||||
ary = env.request.path.split(File::SEPARATOR).select do |part|
|
||||
!part.empty?
|
||||
end
|
||||
ary[0] = @upload_dir
|
||||
path = File.join ary
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
require "zip"
|
||||
require "mime"
|
||||
require "json"
|
||||
require "uri"
|
||||
require "./util"
|
||||
require "./archive"
|
||||
|
||||
struct Image
|
||||
property data : Bytes
|
||||
@@ -25,7 +25,7 @@ class Entry
|
||||
@title = File.basename path, File.extname path
|
||||
@encoded_title = URI.encode @title
|
||||
@size = (File.size path).humanize_bytes
|
||||
file = Zip::File.new path
|
||||
file = ArchiveFile.new path
|
||||
@pages = file.entries.count do |e|
|
||||
["image/jpeg", "image/png"].includes? \
|
||||
MIME.from_filename? e.filename
|
||||
@@ -57,18 +57,19 @@ class Entry
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "/api/page/#{@title_id}/#{@id}/1"
|
||||
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = info_url
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
Zip::File.open @zip_path do |file|
|
||||
img = nil
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
.select { |e|
|
||||
["image/jpeg", "image/png"].includes? \
|
||||
@@ -78,16 +79,13 @@ class Entry
|
||||
compare_alphanumerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
page.open do |io|
|
||||
slice = Bytes.new page.uncompressed_size
|
||||
bytes_read = io.read_fully? slice
|
||||
unless bytes_read
|
||||
return nil
|
||||
end
|
||||
return Image.new slice, MIME.from_filename(page.filename),
|
||||
page.filename, bytes_read
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
end
|
||||
end
|
||||
img
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,7 +95,7 @@ class Title
|
||||
encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(@dir : String, @parent_id, storage,
|
||||
@logger : Logger, @library : Library)
|
||||
@library : Library)
|
||||
@id = storage.get_id @dir, true
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@@ -109,18 +107,22 @@ class Title
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id, storage, @logger, library
|
||||
title = Title.new path, @id, storage, library
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
@library.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
next
|
||||
end
|
||||
if [".zip", ".cbz"].includes? File.extname path
|
||||
zip_exception = validate_zip path
|
||||
unless zip_exception.nil?
|
||||
@logger.warn "File #{path} is corrupted or is not a valid zip " \
|
||||
"archive. Ignoring it."
|
||||
@logger.debug "Zip error: #{zip_exception}"
|
||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||
unless File.readable? path
|
||||
Logger.warn "File #{path} is not readable. Please make sure the " \
|
||||
"file permission is configured correctly."
|
||||
next
|
||||
end
|
||||
archive_exception = validate_archive path
|
||||
unless archive_exception.nil?
|
||||
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
||||
"Archive error: #{archive_exception}"
|
||||
next
|
||||
end
|
||||
entry = Entry.new path, self, @id, storage
|
||||
@@ -231,14 +233,14 @@ class Title
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "img/icon.png"
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
if @entries.size > 0
|
||||
url = @entries[0].cover_url
|
||||
end
|
||||
TitleInfo.new @dir do |info|
|
||||
info_url = info.cover_url
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = info_url
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
@@ -367,9 +369,19 @@ end
|
||||
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
logger : Logger, storage : Storage, title_hash : Hash(String, Title)
|
||||
storage : Storage, title_hash : Hash(String, Title)
|
||||
|
||||
def initialize(@dir, @scan_interval, @logger, @storage)
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
@storage = Storage.default
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@@ -381,7 +393,7 @@ class Library
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
sleep @scan_interval * 60
|
||||
end
|
||||
end
|
||||
@@ -410,8 +422,8 @@ class Library
|
||||
|
||||
def scan
|
||||
unless Dir.exists? @dir
|
||||
@logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
@@ -419,13 +431,13 @@ class Library
|
||||
.select { |fn| !fn.starts_with? "." }
|
||||
.map { |fn| File.join @dir, fn }
|
||||
.select { |path| File.directory? path }
|
||||
.map { |path| Title.new path, "", @storage, @logger, self }
|
||||
.map { |path| Title.new path, "", @storage, self }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
end
|
||||
@logger.debug "Scan completed"
|
||||
Logger.debug "Scan completed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,15 @@ class Logger
|
||||
|
||||
@@severity : Log::Severity = :info
|
||||
|
||||
def initialize(level : String)
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
level = Config.current.log_level
|
||||
{% begin %}
|
||||
case level.downcase
|
||||
when "off"
|
||||
@@ -50,9 +58,16 @@ class Logger
|
||||
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
||||
end
|
||||
|
||||
def self.log(msg)
|
||||
default.log msg
|
||||
end
|
||||
|
||||
{% for lvl in LEVELS %}
|
||||
def {{lvl.id}}(msg)
|
||||
@log.{{lvl.id}} { msg }
|
||||
end
|
||||
def self.{{lvl.id}}(msg)
|
||||
default.not_nil!.{{lvl.id}} msg
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require "http/client"
|
||||
require "json"
|
||||
require "csv"
|
||||
require "../rename"
|
||||
|
||||
macro string_properties(names)
|
||||
{% for name in names %}
|
||||
@@ -14,6 +15,14 @@ macro parse_strings_from_json(names)
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro properties_to_hash(names)
|
||||
{
|
||||
{% for name in names %}
|
||||
"{{name.id}}" => @{{name.id}}.to_s,
|
||||
{% end %}
|
||||
}
|
||||
end
|
||||
|
||||
module MangaDex
|
||||
class Chapter
|
||||
string_properties ["lang_code", "title", "volume", "chapter"]
|
||||
@@ -64,16 +73,20 @@ module MangaDex
|
||||
gname = obj["group_name#{s}"].as_s
|
||||
@groups << {gid, gname}
|
||||
end
|
||||
@full_title = @title
|
||||
unless @chapter.empty?
|
||||
@full_title = "Ch.#{@chapter} " + @full_title
|
||||
end
|
||||
unless @volume.empty?
|
||||
@full_title = "Vol.#{@volume} " + @full_title
|
||||
end
|
||||
|
||||
rename_rule = Rename::Rule.new \
|
||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
||||
@full_title = rename rename_rule
|
||||
rescue e
|
||||
raise "failed to parse json: #{e}"
|
||||
end
|
||||
|
||||
def rename(rule : Rename::Rule)
|
||||
hash = properties_to_hash ["id", "title", "volume", "chapter",
|
||||
"lang_code", "language", "pages"]
|
||||
hash["groups"] = @groups.map { |g| g[1] }.join ","
|
||||
rule.render hash
|
||||
end
|
||||
end
|
||||
|
||||
class Manga
|
||||
@@ -111,10 +124,23 @@ module MangaDex
|
||||
rescue e
|
||||
raise "failed to parse json: #{e}"
|
||||
end
|
||||
|
||||
def rename(rule : Rename::Rule)
|
||||
rule.render properties_to_hash ["id", "title", "author", "artist"]
|
||||
end
|
||||
end
|
||||
|
||||
class API
|
||||
def initialize(@base_url = "https://mangadex.org/api/")
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
||||
"https://mangadex.org/api/"
|
||||
@lang = {} of String => String
|
||||
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
||||
@lang[row[1]] = row[0]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
require "./api"
|
||||
require "sqlite3"
|
||||
require "zip"
|
||||
|
||||
module MangaDex
|
||||
class PageJob
|
||||
@@ -79,12 +80,21 @@ module MangaDex
|
||||
|
||||
class Queue
|
||||
property downloader : Downloader?
|
||||
@path : String
|
||||
|
||||
def initialize(@path : String, @logger : Logger)
|
||||
dir = File.dirname path
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize(db_path : String? = nil)
|
||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
@logger.info "The queue DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
@@ -101,7 +111,7 @@ module MangaDex
|
||||
db.exec "create index if not exists status_idx " \
|
||||
"on queue (status)"
|
||||
rescue e
|
||||
@logger.error "Error when checking tables in DB: #{e}"
|
||||
Logger.error "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
@@ -254,11 +264,22 @@ module MangaDex
|
||||
|
||||
class Downloader
|
||||
property stopped = false
|
||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
||||
.to_i32
|
||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
||||
@library_path : String = Config.current.library_path
|
||||
@downloading = false
|
||||
|
||||
def initialize(@queue : Queue, @api : API, @library_path : String,
|
||||
@wait_seconds : Int32, @retries : Int32,
|
||||
@logger : Logger)
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
@queue = Queue.default
|
||||
@api = API.default
|
||||
@queue.downloader = self
|
||||
|
||||
spawn do
|
||||
@@ -270,7 +291,7 @@ module MangaDex
|
||||
next if job.nil?
|
||||
download job
|
||||
rescue e
|
||||
@logger.error e
|
||||
Logger.error e
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -282,7 +303,7 @@ module MangaDex
|
||||
begin
|
||||
chapter = @api.get_chapter(job.id)
|
||||
rescue e
|
||||
@logger.error e
|
||||
Logger.error e
|
||||
@queue.set_status JobStatus::Error, job
|
||||
unless e.message.nil?
|
||||
@queue.add_message e.message.not_nil!, job
|
||||
@@ -292,7 +313,9 @@ module MangaDex
|
||||
end
|
||||
@queue.set_pages chapter.pages.size, job
|
||||
lib_dir = @library_path
|
||||
manga_dir = File.join lib_dir, chapter.manga.title
|
||||
rename_rule = Rename::Rule.new \
|
||||
Config.current.mangadex["manga_rename_rule"].to_s
|
||||
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
|
||||
unless File.exists? manga_dir
|
||||
Dir.mkdir_p manga_dir
|
||||
end
|
||||
@@ -310,16 +333,16 @@ module MangaDex
|
||||
ext = File.extname fn
|
||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||
page_job = PageJob.new url, fn, writer, @retries
|
||||
@logger.debug "Downloading #{url}"
|
||||
Logger.debug "Downloading #{url}"
|
||||
loop do
|
||||
sleep @wait_seconds.seconds
|
||||
download_page page_job
|
||||
break if page_job.success ||
|
||||
page_job.tries_remaning <= 0
|
||||
page_job.tries_remaning -= 1
|
||||
@logger.warn "Failed to download page #{url}. " \
|
||||
"Retrying... Remaining retries: " \
|
||||
"#{page_job.tries_remaning}"
|
||||
Logger.warn "Failed to download page #{url}. " \
|
||||
"Retrying... Remaining retries: " \
|
||||
"#{page_job.tries_remaning}"
|
||||
end
|
||||
|
||||
channel.send page_job
|
||||
@@ -330,8 +353,8 @@ module MangaDex
|
||||
page_jobs = [] of PageJob
|
||||
chapter.pages.size.times do
|
||||
page_job = channel.receive
|
||||
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||
"#{page_job.url}"
|
||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||
"#{page_job.url}"
|
||||
page_jobs << page_job
|
||||
if page_job.success
|
||||
@queue.add_success job
|
||||
@@ -339,16 +362,16 @@ module MangaDex
|
||||
@queue.add_fail job
|
||||
msg = "Failed to download page #{page_job.url}"
|
||||
@queue.add_message msg, job
|
||||
@logger.error msg
|
||||
Logger.error msg
|
||||
end
|
||||
end
|
||||
fail_count = page_jobs.count { |j| !j.success }
|
||||
@logger.debug "Download completed. " \
|
||||
"#{fail_count}/#{page_jobs.size} failed"
|
||||
Logger.debug "Download completed. " \
|
||||
"#{fail_count}/#{page_jobs.size} failed"
|
||||
writer.close
|
||||
@logger.debug "cbz File created at #{zip_path}"
|
||||
Logger.debug "cbz File created at #{zip_path}"
|
||||
|
||||
zip_exception = validate_zip zip_path
|
||||
zip_exception = validate_archive zip_path
|
||||
if !zip_exception.nil?
|
||||
@queue.add_message "The downloaded archive is corrupted. " \
|
||||
"Error: #{zip_exception}", job
|
||||
@@ -363,7 +386,7 @@ module MangaDex
|
||||
end
|
||||
|
||||
private def download_page(job : PageJob)
|
||||
@logger.debug "downloading #{job.url}"
|
||||
Logger.debug "downloading #{job.url}"
|
||||
headers = HTTP::Headers{
|
||||
"User-agent" => "Mangadex.cr",
|
||||
}
|
||||
@@ -377,7 +400,7 @@ module MangaDex
|
||||
end
|
||||
job.success = true
|
||||
rescue e
|
||||
@logger.error e
|
||||
Logger.error e
|
||||
job.success = false
|
||||
end
|
||||
end
|
||||
|
||||
124
src/mango.cr
124
src/mango.cr
@@ -1,41 +1,103 @@
|
||||
require "./config"
|
||||
require "./server"
|
||||
require "./context"
|
||||
require "./mangadex/*"
|
||||
require "option_parser"
|
||||
require "clim"
|
||||
|
||||
VERSION = "0.3.0"
|
||||
MANGO_VERSION = "0.5.2"
|
||||
|
||||
config_path = nil
|
||||
macro common_option
|
||||
option "-c PATH", "--config=PATH", type: String,
|
||||
desc: "Path to the config file"
|
||||
end
|
||||
|
||||
OptionParser.parse do |parser|
|
||||
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
|
||||
macro throw(msg)
|
||||
puts "ERROR: #{{{msg}}}"
|
||||
puts
|
||||
puts "Please see the `--help`."
|
||||
exit 1
|
||||
end
|
||||
|
||||
parser.on "-v", "--version", "Show version" do
|
||||
puts "Version #{VERSION}"
|
||||
exit
|
||||
end
|
||||
parser.on "-h", "--help", "Show help" do
|
||||
puts parser
|
||||
exit
|
||||
end
|
||||
parser.on "-c PATH", "--config=PATH",
|
||||
"Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
|
||||
config_path = path
|
||||
class CLI < Clim
|
||||
main do
|
||||
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
||||
usage "mango [sub_command] [options]"
|
||||
help short: "-h"
|
||||
version "Version #{MANGO_VERSION}", short: "-v"
|
||||
common_option
|
||||
run do |opts|
|
||||
Config.load(opts.config).set_current
|
||||
MangaDex::Downloader.default
|
||||
|
||||
# empty ARGV so it won't be passed to Kemal
|
||||
ARGV.clear
|
||||
server = Server.new
|
||||
server.start
|
||||
end
|
||||
|
||||
sub "admin" do
|
||||
desc "Run admin tools"
|
||||
usage "mango admin [tool]"
|
||||
help short: "-h"
|
||||
run do |opts|
|
||||
puts opts.help_string
|
||||
end
|
||||
sub "user" do
|
||||
desc "User management tool"
|
||||
usage "mango admin user [arguments] [options]"
|
||||
help short: "-h"
|
||||
argument "action", type: String,
|
||||
desc: "Action to perform. Can be add/delete/update/list"
|
||||
argument "username", type: String,
|
||||
desc: "Username to update or delete"
|
||||
option "-u USERNAME", "--username=USERNAME", type: String,
|
||||
desc: "Username"
|
||||
option "-p PASSWORD", "--password=PASSWORD", type: String,
|
||||
desc: "Password"
|
||||
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
|
||||
common_option
|
||||
run do |opts, args|
|
||||
Config.load(opts.config).set_current
|
||||
storage = Storage.new nil, false
|
||||
|
||||
case args.action
|
||||
when "add"
|
||||
throw "Options `-u` and `-p` required." if opts.username.nil? ||
|
||||
opts.password.nil?
|
||||
storage.new_user opts.username.not_nil!,
|
||||
opts.password.not_nil!, opts.admin
|
||||
when "delete"
|
||||
throw "Argument `username` required." if args.username.nil?
|
||||
storage.delete_user args.username
|
||||
when "update"
|
||||
throw "Argument `username` required." if args.username.nil?
|
||||
username = opts.username || args.username
|
||||
password = opts.password || ""
|
||||
storage.update_user args.username, username.not_nil!,
|
||||
password.not_nil!, opts.admin
|
||||
when "list"
|
||||
users = storage.list_users
|
||||
name_length = users.map(&.[0].size).max? || 0
|
||||
l_cell_width = ["username".size, name_length].max
|
||||
r_cell_width = "admin access".size
|
||||
header = " #{"username".ljust l_cell_width} | admin access "
|
||||
puts "-" * header.size
|
||||
puts header
|
||||
puts "-" * header.size
|
||||
users.each do |name, admin|
|
||||
puts " #{name.ljust l_cell_width} | " \
|
||||
"#{admin.to_s.ljust r_cell_width} "
|
||||
end
|
||||
puts "-" * header.size
|
||||
when nil
|
||||
puts opts.help_string
|
||||
else
|
||||
throw "Unknown action \"#{args.action}\"."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
config = Config.load config_path
|
||||
logger = Logger.new config.log_level
|
||||
storage = Storage.new config.db_path, logger
|
||||
library = Library.new config.library_path, config.scan_interval, logger, storage
|
||||
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
|
||||
logger
|
||||
api = MangaDex::API.new config.mangadex["api_url"].to_s
|
||||
MangaDex::Downloader.new queue, api, config.library_path,
|
||||
config.mangadex["download_wait_seconds"].to_i,
|
||||
config.mangadex["download_retries"].to_i, logger
|
||||
|
||||
context = Context.new config, logger, library, storage, queue
|
||||
|
||||
server = Server.new context
|
||||
server.start
|
||||
CLI.start(ARGV)
|
||||
|
||||
141
src/rename.cr
Normal file
141
src/rename.cr
Normal file
@@ -0,0 +1,141 @@
|
||||
module Rename
|
||||
alias VHash = Hash(String, String)
|
||||
|
||||
abstract class Base(T)
|
||||
@ary = [] of T
|
||||
|
||||
def push(var)
|
||||
@ary.push var
|
||||
end
|
||||
|
||||
abstract def render(hash : VHash)
|
||||
end
|
||||
|
||||
class Variable < Base(String)
|
||||
property id : String
|
||||
|
||||
def initialize(@id)
|
||||
end
|
||||
|
||||
def render(hash : VHash)
|
||||
hash[@id]? || ""
|
||||
end
|
||||
end
|
||||
|
||||
class Pattern < Base(Variable)
|
||||
def render(hash : VHash)
|
||||
@ary.each do |v|
|
||||
if hash.has_key? v.id
|
||||
return v.render hash
|
||||
end
|
||||
end
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
class Group < Base(Pattern | String)
|
||||
def render(hash : VHash)
|
||||
return "" if @ary.select(&.is_a? Pattern)
|
||||
.any? &.as(Pattern).render(hash).empty?
|
||||
@ary.map do |e|
|
||||
if e.is_a? Pattern
|
||||
e.render hash
|
||||
else
|
||||
e
|
||||
end
|
||||
end.join
|
||||
end
|
||||
end
|
||||
|
||||
class Rule < Base(Group | String | Pattern)
|
||||
ESCAPE = ['/']
|
||||
|
||||
def initialize(str : String)
|
||||
parse! str
|
||||
rescue e
|
||||
raise "Failed to parse rename rule #{str}. Error: #{e}"
|
||||
end
|
||||
|
||||
private def parse!(str : String)
|
||||
chars = [] of Char
|
||||
pattern : Pattern? = nil
|
||||
group : Group? = nil
|
||||
|
||||
str.each_char_with_index do |char, i|
|
||||
if ['[', ']', '{', '}', '|'].includes?(char) && !chars.empty?
|
||||
string = chars.join
|
||||
if !pattern.nil?
|
||||
pattern.push Variable.new string.strip
|
||||
elsif !group.nil?
|
||||
group.push string
|
||||
else
|
||||
@ary.push string
|
||||
end
|
||||
chars = [] of Char
|
||||
end
|
||||
|
||||
case char
|
||||
when '['
|
||||
if !group.nil? || !pattern.nil?
|
||||
raise "nested groups are not allowed"
|
||||
end
|
||||
group = Group.new
|
||||
when ']'
|
||||
if group.nil?
|
||||
raise "unmatched ] at position #{i}"
|
||||
end
|
||||
if !pattern.nil?
|
||||
raise "patterns (`{}`) should be closed before closing the " \
|
||||
"group (`[]`)"
|
||||
end
|
||||
@ary.push group
|
||||
group = nil
|
||||
when '{'
|
||||
if !pattern.nil?
|
||||
raise "nested patterns are not allowed"
|
||||
end
|
||||
pattern = Pattern.new
|
||||
when '}'
|
||||
if pattern.nil?
|
||||
raise "unmatched } at position #{i}"
|
||||
end
|
||||
if !group.nil?
|
||||
group.push pattern
|
||||
else
|
||||
@ary.push pattern
|
||||
end
|
||||
pattern = nil
|
||||
when '|'
|
||||
if pattern.nil?
|
||||
chars.push char
|
||||
end
|
||||
else
|
||||
if ESCAPE.includes? char
|
||||
raise "the character #{char} at position #{i} is not allowed"
|
||||
end
|
||||
chars.push char
|
||||
end
|
||||
end
|
||||
|
||||
unless chars.empty?
|
||||
@ary.push chars.join
|
||||
end
|
||||
if !pattern.nil?
|
||||
raise "unclosed pattern {"
|
||||
end
|
||||
if !group.nil?
|
||||
raise "unclosed group ["
|
||||
end
|
||||
end
|
||||
|
||||
def render(hash : VHash)
|
||||
@ary.map do |e|
|
||||
if e.is_a? String
|
||||
e
|
||||
else
|
||||
e.render hash
|
||||
end
|
||||
end.join.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require "./router"
|
||||
|
||||
class AdminRouter < Router
|
||||
def setup
|
||||
def initialize
|
||||
get "/admin" do |env|
|
||||
layout "admin"
|
||||
end
|
||||
@@ -32,29 +32,15 @@ class AdminRouter < Router
|
||||
# would not contain `admin`
|
||||
admin = !env.params.body["admin"]?.nil?
|
||||
|
||||
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"
|
||||
end
|
||||
if password.size < 6
|
||||
raise "Password should contain at least 6 characters"
|
||||
end
|
||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||
raise "password should contain ASCII characters only"
|
||||
end
|
||||
|
||||
@context.storage.new_user username, password, admin
|
||||
|
||||
env.redirect "/admin/user"
|
||||
redirect env, "/admin/user"
|
||||
rescue e
|
||||
@context.error e
|
||||
redirect_url = URI.new \
|
||||
path: "/admin/user/edit",
|
||||
query: hash_to_query({"error" => e.message})
|
||||
env.redirect redirect_url.to_s
|
||||
redirect env, redirect_url.to_s
|
||||
end
|
||||
|
||||
post "/admin/user/edit/:original_username" do |env|
|
||||
@@ -65,38 +51,21 @@ class AdminRouter < Router
|
||||
admin = !env.params.body["admin"]?.nil?
|
||||
original_username = env.params.url["original_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"
|
||||
end
|
||||
|
||||
if password.size != 0
|
||||
if password.size < 6
|
||||
raise "Password should contain at least 6 characters"
|
||||
end
|
||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||
raise "password should contain ASCII characters only"
|
||||
end
|
||||
end
|
||||
|
||||
@context.storage.update_user \
|
||||
original_username, username, password, admin
|
||||
|
||||
env.redirect "/admin/user"
|
||||
redirect env, "/admin/user"
|
||||
rescue e
|
||||
@context.error e
|
||||
redirect_url = URI.new \
|
||||
path: "/admin/user/edit",
|
||||
query: hash_to_query({"username" => original_username, \
|
||||
"admin" => admin, "error" => e.message})
|
||||
env.redirect redirect_url.to_s
|
||||
redirect env, redirect_url.to_s
|
||||
end
|
||||
|
||||
get "/admin/downloads" do |env|
|
||||
base_url = @context.config.mangadex["base_url"]
|
||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||
layout "download-manager"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ require "../mangadex/*"
|
||||
require "../upload"
|
||||
|
||||
class APIRouter < Router
|
||||
def setup
|
||||
def initialize
|
||||
get "/api/page/:tid/:eid/:page" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -123,7 +123,7 @@ class APIRouter < Router
|
||||
get "/api/admin/mangadex/manga/:id" do |env|
|
||||
begin
|
||||
id = env.params.url["id"]
|
||||
api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
|
||||
api = MangaDex::API.default
|
||||
manga = api.get_manga id
|
||||
send_json env, manga.to_info_json
|
||||
rescue e
|
||||
@@ -230,7 +230,7 @@ class APIRouter < Router
|
||||
end
|
||||
|
||||
ext = File.extname filename
|
||||
upload = Upload.new @context.config.upload_path, @context.logger
|
||||
upload = Upload.new Config.current.upload_path
|
||||
url = upload.path_to_url upload.save "img", ext, part.body
|
||||
|
||||
if url.nil?
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
require "./router"
|
||||
|
||||
class MainRouter < Router
|
||||
def setup
|
||||
def initialize
|
||||
get "/login" do |env|
|
||||
base_url = Config.current.base_url
|
||||
render "src/views/login.ecr"
|
||||
end
|
||||
|
||||
get "/logout" do |env|
|
||||
begin
|
||||
cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
|
||||
cookie = env.request.cookies.find do |c|
|
||||
c.name == "token-#{Config.current.port}"
|
||||
end.not_nil!
|
||||
@context.storage.logout cookie.value
|
||||
rescue e
|
||||
@context.error "Error when attempting to log out: #{e}"
|
||||
ensure
|
||||
env.redirect "/login"
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,12 +26,13 @@ class MainRouter < Router
|
||||
password = env.params.body["password"]
|
||||
token = @context.storage.verify_user(username, password).not_nil!
|
||||
|
||||
cookie = HTTP::Cookie.new "token", token
|
||||
cookie = HTTP::Cookie.new "token-#{Config.current.port}", token
|
||||
cookie.path = Config.current.base_url
|
||||
cookie.expires = Time.local.shift years: 1
|
||||
env.response.cookies << cookie
|
||||
env.redirect "/"
|
||||
redirect env, "/"
|
||||
rescue
|
||||
env.redirect "/login"
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +63,7 @@ class MainRouter < Router
|
||||
end
|
||||
|
||||
get "/download" do |env|
|
||||
base_url = @context.config.mangadex["base_url"]
|
||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||
layout "download"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "./router"
|
||||
|
||||
class ReaderRouter < Router
|
||||
def setup
|
||||
def initialize
|
||||
get "/reader/:title/:entry" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
@@ -15,7 +15,7 @@ class ReaderRouter < Router
|
||||
# might not have actually read them
|
||||
page = [page - 2 * IMGS_PER_PAGE, 1].max
|
||||
|
||||
env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
|
||||
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 404
|
||||
@@ -24,6 +24,8 @@ class ReaderRouter < Router
|
||||
|
||||
get "/reader/:title/:entry/:page" do |env|
|
||||
begin
|
||||
base_url = Config.current.base_url
|
||||
|
||||
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
|
||||
@@ -35,20 +37,20 @@ class ReaderRouter < Router
|
||||
|
||||
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
|
||||
urls = pages.map { |idx|
|
||||
"/api/page/#{title.id}/#{entry.id}/#{idx}"
|
||||
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
|
||||
}
|
||||
reader_urls = pages.map { |idx|
|
||||
"/reader/#{title.id}/#{entry.id}/#{idx}"
|
||||
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
|
||||
}
|
||||
next_page = page + IMGS_PER_PAGE
|
||||
next_url = next_entry_url = nil
|
||||
exit_url = "/book/#{title.id}"
|
||||
exit_url = "#{base_url}book/#{title.id}"
|
||||
next_entry = title.next_entry entry
|
||||
unless next_page > entry.pages
|
||||
next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||
end
|
||||
unless next_entry.nil?
|
||||
next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
|
||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
||||
end
|
||||
|
||||
render "src/views/reader.ecr"
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
require "../context"
|
||||
|
||||
class Router
|
||||
def initialize(@context : Context)
|
||||
end
|
||||
@context : Context = Context.default
|
||||
end
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
require "kemal"
|
||||
require "./context"
|
||||
require "./library"
|
||||
require "./handlers/*"
|
||||
require "./util"
|
||||
require "./routes/*"
|
||||
|
||||
class Context
|
||||
property library : Library
|
||||
property storage : Storage
|
||||
property queue : MangaDex::Queue
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
@storage = Storage.default
|
||||
@library = Library.default
|
||||
@queue = MangaDex::Queue.default
|
||||
end
|
||||
|
||||
{% for lvl in Logger::LEVELS %}
|
||||
def {{lvl.id}}(msg)
|
||||
Logger.{{lvl.id}} msg
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
class Server
|
||||
def initialize(@context : Context)
|
||||
@context : Context = Context.default
|
||||
|
||||
def initialize
|
||||
error 403 do |env|
|
||||
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
|
||||
layout "message"
|
||||
@@ -14,20 +41,23 @@ class Server
|
||||
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
||||
layout "message"
|
||||
end
|
||||
error 500 do |env|
|
||||
message = "HTTP 500: Internal server error. Please try again later."
|
||||
layout "message"
|
||||
end
|
||||
|
||||
MainRouter.new(@context).setup
|
||||
AdminRouter.new(@context).setup
|
||||
ReaderRouter.new(@context).setup
|
||||
APIRouter.new(@context).setup
|
||||
{% if flag?(:release) %}
|
||||
error 500 do |env|
|
||||
message = "HTTP 500: Internal server error. Please try again later."
|
||||
layout "message"
|
||||
end
|
||||
{% end %}
|
||||
|
||||
MainRouter.new
|
||||
AdminRouter.new
|
||||
ReaderRouter.new
|
||||
APIRouter.new
|
||||
|
||||
Kemal.config.logging = false
|
||||
add_handler LogHandler.new @context.logger
|
||||
add_handler LogHandler.new
|
||||
add_handler AuthHandler.new @context.storage
|
||||
add_handler UploadHandler.new @context.config.upload_path
|
||||
add_handler UploadHandler.new Config.current.upload_path
|
||||
{% if flag?(:release) %}
|
||||
# when building for relase, embed the static files in binary
|
||||
@context.debug "We are in release mode. Using embedded static files."
|
||||
@@ -41,7 +71,7 @@ class Server
|
||||
{% if flag?(:release) %}
|
||||
Kemal.config.env = "production"
|
||||
{% end %}
|
||||
Kemal.config.port = @context.config.port
|
||||
Kemal.config.port = Config.current.port
|
||||
Kemal.run
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,14 +13,24 @@ def verify_password(hash, pw)
|
||||
end
|
||||
|
||||
class Storage
|
||||
def initialize(@path : String, @logger : Logger)
|
||||
dir = File.dirname path
|
||||
@path : String
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize(db_path : String? = nil, init_user = true)
|
||||
@path = db_path || Config.current.db_path
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
@logger.info "The DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Logger.info "The DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{path}" do |db|
|
||||
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,
|
||||
@@ -34,23 +44,33 @@ class Storage
|
||||
"(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}"
|
||||
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}"
|
||||
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)"
|
||||
random_pw = random_str
|
||||
hash = hash_password random_pw
|
||||
db.exec "insert into users values (?, ?, ?, ?)",
|
||||
"admin", hash, nil, 1
|
||||
@logger.log "Initial user created. You can log in with " \
|
||||
"#{{"username" => "admin", "password" => random_pw}}"
|
||||
|
||||
init_admin if init_user
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
macro init_admin
|
||||
random_pw = random_str
|
||||
hash = hash_password random_pw
|
||||
db.exec "insert into users values (?, ?, ?, ?)",
|
||||
"admin", hash, nil, 1
|
||||
Logger.log "Initial user created. You can log in with " \
|
||||
"#{{"username" => "admin", "password" => random_pw}}"
|
||||
end
|
||||
|
||||
def verify_user(username, password)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
@@ -58,18 +78,18 @@ class Storage
|
||||
"users where username = (?)",
|
||||
username, as: {String, String?}
|
||||
unless verify_password hash, password
|
||||
@logger.debug "Password does not match the hash"
|
||||
Logger.debug "Password does not match the hash"
|
||||
return nil
|
||||
end
|
||||
@logger.debug "User #{username} verified"
|
||||
Logger.debug "User #{username} verified"
|
||||
return token if token
|
||||
token = random_str
|
||||
@logger.debug "Updating token for #{username}"
|
||||
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}"
|
||||
Logger.error "Error when verifying user #{username}: #{e}"
|
||||
return nil
|
||||
end
|
||||
end
|
||||
@@ -82,7 +102,7 @@ class Storage
|
||||
username = db.query_one "select username from users where " \
|
||||
"token = (?)", token, as: String
|
||||
rescue e
|
||||
@logger.debug "Unable to verify token"
|
||||
Logger.debug "Unable to verify token"
|
||||
end
|
||||
end
|
||||
username
|
||||
@@ -95,7 +115,7 @@ class Storage
|
||||
is_admin = db.query_one "select admin from users where " \
|
||||
"token = (?)", token, as: Bool
|
||||
rescue e
|
||||
@logger.debug "Unable to verify user as admin"
|
||||
Logger.debug "Unable to verify user as admin"
|
||||
end
|
||||
end
|
||||
is_admin
|
||||
@@ -114,6 +134,8 @@ class Storage
|
||||
end
|
||||
|
||||
def new_user(username, password, admin)
|
||||
validate_username username
|
||||
validate_password password
|
||||
admin = (admin ? 1 : 0)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
hash = hash_password password
|
||||
@@ -124,8 +146,10 @@ class Storage
|
||||
|
||||
def update_user(original_username, username, password, admin)
|
||||
admin = (admin ? 1 : 0)
|
||||
validate_username username
|
||||
validate_password password unless password.empty?
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
if password.size == 0
|
||||
if password.empty?
|
||||
db.exec "update users set username = (?), admin = (?) " \
|
||||
"where username = (?)",
|
||||
username, admin, original_username
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require "./util"
|
||||
|
||||
class Upload
|
||||
def initialize(@dir : String, @logger : Logger)
|
||||
def initialize(@dir : String)
|
||||
unless Dir.exists? @dir
|
||||
@logger.info "The uploads directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Logger.info "The uploads directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,7 @@ class Upload
|
||||
file_path = File.join full_dir, filename
|
||||
|
||||
unless Dir.exists? full_dir
|
||||
@logger.debug "creating directory #{full_dir}"
|
||||
Logger.debug "creating directory #{full_dir}"
|
||||
Dir.mkdir_p full_dir
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class Upload
|
||||
end
|
||||
|
||||
if ary.empty?
|
||||
@logger.warn "File #{path} is not in the upload directory #{@dir}"
|
||||
Logger.warn "File #{path} is not in the upload directory #{@dir}"
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
42
src/util.cr
42
src/util.cr
@@ -4,8 +4,11 @@ IMGS_PER_PAGE = 5
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
|
||||
macro layout(name)
|
||||
base_url = Config.current.base_url
|
||||
begin
|
||||
cookie = env.request.cookies.find { |c| c.name == "token" }
|
||||
cookie = env.request.cookies.find do |c|
|
||||
c.name == "token-#{Config.current.port}"
|
||||
end
|
||||
is_admin = false
|
||||
unless cookie.nil?
|
||||
is_admin = @context.storage.verify_admin cookie.value
|
||||
@@ -25,7 +28,9 @@ end
|
||||
macro get_username(env)
|
||||
# if the request gets here, it has gone through the auth handler, and
|
||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
|
||||
cookie = {{env}}.request.cookies.find do |c|
|
||||
c.name == "token-#{Config.current.port}"
|
||||
end.not_nil!
|
||||
(@context.storage.verify_token cookie.value).not_nil!
|
||||
end
|
||||
|
||||
@@ -84,12 +89,9 @@ def compare_alphanumerically(a : String, b : String)
|
||||
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
||||
end
|
||||
|
||||
# When downloading from MangaDex, the zip/cbz file would not be valid
|
||||
# before the download is completed. If we scan the zip file,
|
||||
# Entry.new would throw, so we use this method to check before
|
||||
# constructing Entry
|
||||
def validate_zip(path : String) : Exception?
|
||||
file = Zip::File.new path
|
||||
def validate_archive(path : String) : Exception?
|
||||
file = ArchiveFile.new path
|
||||
file.check
|
||||
file.close
|
||||
return
|
||||
rescue e
|
||||
@@ -99,3 +101,27 @@ end
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
end
|
||||
|
||||
def redirect(env, path)
|
||||
base = Config.current.base_url
|
||||
env.redirect File.join base, path
|
||||
end
|
||||
|
||||
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"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_password(password)
|
||||
if password.size < 6
|
||||
raise "Password should contain at least 6 characters"
|
||||
end
|
||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||
raise "password should contain ASCII characters only"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<ul class="uk-list uk-list-large uk-list-divider">
|
||||
<li data-url="/admin/user">User Managerment</li>
|
||||
<li data-url="<%= base_url %>admin/user">User Managerment</li>
|
||||
<li onclick="if(!scanning){scan()}">
|
||||
<span id="scan">Scan Library Files</span>
|
||||
<span id="scan-status" class="uk-align-right">
|
||||
@@ -7,12 +7,12 @@
|
||||
<span hidden></span>
|
||||
</span>
|
||||
</li>
|
||||
<li data-url="/admin/downloads">Download Manager</li>
|
||||
<li data-url="<%= base_url %>admin/downloads">Download Manager</li>
|
||||
</ul>
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
<a class="uk-button uk-button-danger" href="/logout">Log Out</a>
|
||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="/js/admin.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/admin.js"></script>
|
||||
<% end %>
|
||||
@@ -1,32 +1,32 @@
|
||||
<div class="uk-margin">
|
||||
<div id="actions" class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
||||
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
||||
</div>
|
||||
<div id="config" class="uk-margin">
|
||||
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
||||
</div>
|
||||
<div id="actions" class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
||||
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
||||
</div>
|
||||
<div id="config" class="uk-margin">
|
||||
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Manga</th>
|
||||
<th>Progress</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Manga</th>
|
||||
<th>Progress</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script>
|
||||
var baseURL = "<%= base_url %>".replace(/\/$/, "");
|
||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<script src="/js/alert.js"></script>
|
||||
<script src="/js/download-manager.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||
<% end %>
|
||||
@@ -1,83 +1,83 @@
|
||||
<h2 class=uk-title>Download from MangaDex</h2>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-3-4">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||
</div>
|
||||
<div class="uk-width-1-4">
|
||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||
</div>
|
||||
<div class="uk-width-3-4">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||
</div>
|
||||
<div class="uk-width-1-4">
|
||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
||||
<div class="uk-width-1-4@s">
|
||||
<img id="cover">
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<p id="title"></p>
|
||||
<p id="artist"></p>
|
||||
<p id="author"></p>
|
||||
</div>
|
||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="lang-select">Language</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="lang-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="group-select">Group</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="group-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<img id="cover">
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<p id="title"></p>
|
||||
<p id="artist"></p>
|
||||
<p id="author"></p>
|
||||
</div>
|
||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="lang-select">Language</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="lang-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="group-select">Group</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="group-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="selection-controls" class="uk-margin" hidden>
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
</div>
|
||||
<p id="filter-notification" hidden></p>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Language</th>
|
||||
<th>Group</th>
|
||||
<th>Volume</th>
|
||||
<th>Chapter</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Language</th>
|
||||
<th>Group</th>
|
||||
<th>Volume</th>
|
||||
<th>Chapter</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script>
|
||||
var baseURL = "<%= base_url %>".replace(/\/$/, "");
|
||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<script src="/js/alert.js"></script>
|
||||
<script src="/js/download.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download.js"></script>
|
||||
<% end %>
|
||||
@@ -1,49 +1,49 @@
|
||||
<h2 class=uk-title>Library</h2>
|
||||
<p class="uk-text-meta"><%= titles.size %> titles found</p>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||
<form class="uk-search uk-search-default">
|
||||
<span uk-search-icon></span>
|
||||
<input class="uk-search-input" type="search" placeholder="Search">
|
||||
</form>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<div class="uk-form-horizontal">
|
||||
<select class="uk-select" id="sort-select">
|
||||
<option id="name-up">â–˛ Name</option>
|
||||
<option id="name-down">â–Ľ Name</option>
|
||||
<option id="date-up">â–˛ Date Modified</option>
|
||||
<option id="date-down">â–Ľ Date Modified</option>
|
||||
<option id="progress-up">â–˛ Progress</option>
|
||||
<option id="progress-down">â–Ľ Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||
<form class="uk-search uk-search-default">
|
||||
<span uk-search-icon></span>
|
||||
<input class="uk-search-input" type="search" placeholder="Search">
|
||||
</form>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<div class="uk-form-horizontal">
|
||||
<select class="uk-select" id="sort-select">
|
||||
<option id="name-up">â–˛ Name</option>
|
||||
<option id="name-down">â–Ľ Name</option>
|
||||
<option id="date-up">â–˛ Date Modified</option>
|
||||
<option id="date-down">â–Ľ Date Modified</option>
|
||||
<option id="progress-up">â–˛ Progress</option>
|
||||
<option id="progress-down">â–Ľ Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- titles.each_with_index do |t, i| -%>
|
||||
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
|
||||
<a class="acard" href="/book/<%= t.id %>">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<%- if t.entries.size > 0 -%>
|
||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
||||
<%- end -%>
|
||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
||||
<p><%= t.size %> entries</p>
|
||||
</div>
|
||||
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<%- if t.entries.size > 0 -%>
|
||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
||||
<%- end -%>
|
||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
||||
<p><%= t.size %> entries</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="/js/dots.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/sort-items.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
@@ -1,75 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango Manga Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<link rel="stylesheet" href="/css/mango.css" />
|
||||
<script defer src="/js/fontawesome.min.js"></script>
|
||||
<script defer src="/js/solid.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="uk-offcanvas-content">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
||||
<li><a href="/">Home</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<li><a href="/download">Download</a></li>
|
||||
<% end %>
|
||||
<hr uk-divider>
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-position-top">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div class="uk-navbar-left uk-hidden@s">
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@s">
|
||||
<a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/">Home</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<li><a href="/download">Download</a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="uk-navbar-right uk-visible@s">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||
<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 src="<%= base_url %>js/theme.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="uk-offcanvas-content">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li><a href="<%= base_url %>download">Download</a></li>
|
||||
<% end %>
|
||||
<hr uk-divider>
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
<div class="uk-container uk-container-small">
|
||||
<div id="alert"></div>
|
||||
<%= content %>
|
||||
</div>
|
||||
<div class="uk-position-top">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div class="uk-navbar-left uk-hidden@s">
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@s">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li><a href="<%= base_url %>download">Download</a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="uk-navbar-right uk-visible@s">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
<div class="uk-container uk-container-small">
|
||||
<div id="alert"></div>
|
||||
<%= content %>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
const base_url = "<%= base_url %>";
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango Manga Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango Manga Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="<%= base_url %>js/theme.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
||||
<div class="uk-width-1-1">
|
||||
<div class="uk-container">
|
||||
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
||||
<div class="uk-width-1-1@m">
|
||||
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
|
||||
<h3 class="uk-card-title uk-text-center">Log In</h3>
|
||||
<form action="/login" method="post">
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
|
||||
</div>
|
||||
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
||||
<div class="uk-width-1-1">
|
||||
<div class="uk-container">
|
||||
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
||||
<div class="uk-width-1-1@m">
|
||||
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
|
||||
<h3 class="uk-card-title uk-text-center">Log In</h3>
|
||||
<form action="<%= base_url %>login" method="post">
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
|
||||
</div>
|
||||
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1 +1 @@
|
||||
<p class="uk-text-lead uk-text-center"><%= message %></p>
|
||||
<p class="uk-text-lead uk-text-center"><%= message %></p>
|
||||
@@ -1,65 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="reader-bg">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango Manga Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<link rel="stylesheet" href="/css/mango.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="/js/theme.js"></script>
|
||||
<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 %>" 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>
|
||||
<%- 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>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango</title>
|
||||
<meta name="description" content="Mango Manga Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="<%= base_url %>js/theme.js"></script>
|
||||
<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 %>" 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>
|
||||
<%- 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="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>
|
||||
<div class="uk-modal-header">
|
||||
<h3 class="uk-modal-title">Options</h3>
|
||||
<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>
|
||||
<div class="uk-modal-header">
|
||||
<h3 class="uk-modal-title">Options</h3>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
<p id="progress-label"></p>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
<p id="progress-label"></p>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="page-select" class="uk-select">
|
||||
<%- (1..entry.pages).each do |p| -%>
|
||||
<option value="<%= p %>"><%= p %></option>
|
||||
<%- end -%>
|
||||
</select>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="page-select" class="uk-select">
|
||||
<%- (1..entry.pages).each do |p| -%>
|
||||
<option value="<%= p %>"><%= p %></option>
|
||||
<%- end -%>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
|
||||
<script src="/js/reader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
<script>
|
||||
const base_url = "<%= base_url %>"
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
|
||||
<script src="<%= base_url %>js/reader.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,72 +1,72 @@
|
||||
<div>
|
||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||
|
||||
<% if is_admin %>
|
||||
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||
<% end %>
|
||||
</h2>
|
||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||
|
||||
<% if is_admin %>
|
||||
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||
<% end %>
|
||||
</h2>
|
||||
</div>
|
||||
<ul class="uk-breadcrumb">
|
||||
<li><a href="/">Library</a></li>
|
||||
<%- title.parents.each do |t| -%>
|
||||
<li><a href="/book/<%= t.id %>"><%= t.display_name %></a></li>
|
||||
<%- end -%>
|
||||
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
||||
<li><a href="<%= base_url %>">Library</a></li>
|
||||
<%- title.parents.each do |t| -%>
|
||||
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
|
||||
<%- end -%>
|
||||
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
||||
</ul>
|
||||
<p class="uk-text-meta"><%= title.size %> entries found</p>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||
<form class="uk-search uk-search-default">
|
||||
<span uk-search-icon></span>
|
||||
<input class="uk-search-input" type="search" placeholder="Search">
|
||||
</form>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<div class="uk-form-horizontal">
|
||||
<select class="uk-select" id="sort-select">
|
||||
<option id="auto-up">â–˛ Auto</option>
|
||||
<option id="auto-down">â–Ľ Auto</option>
|
||||
<option id="name-up">â–˛ Name</option>
|
||||
<option id="name-down">â–Ľ Name</option>
|
||||
<option id="date-up">â–˛ Date Modified</option>
|
||||
<option id="date-down">â–Ľ Date Modified</option>
|
||||
<option id="progress-up">â–˛ Progress</option>
|
||||
<option id="progress-down">â–Ľ Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||
<form class="uk-search uk-search-default">
|
||||
<span uk-search-icon></span>
|
||||
<input class="uk-search-input" type="search" placeholder="Search">
|
||||
</form>
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<div class="uk-form-horizontal">
|
||||
<select class="uk-select" id="sort-select">
|
||||
<option id="auto-up">â–˛ Auto</option>
|
||||
<option id="auto-down">â–Ľ Auto</option>
|
||||
<option id="name-up">â–˛ Name</option>
|
||||
<option id="name-down">â–Ľ Name</option>
|
||||
<option id="date-up">â–˛ Date Modified</option>
|
||||
<option id="date-down">â–Ľ Date Modified</option>
|
||||
<option id="progress-up">â–˛ Progress</option>
|
||||
<option id="progress-down">â–Ľ Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- title.titles.each_with_index do |t, i| -%>
|
||||
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
|
||||
<a class="acard" href="/book/<%= t.id %>">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
||||
<p><%= t.size %> entries</p>
|
||||
</div>
|
||||
<%- title.titles.each_with_index do |t, i| -%>
|
||||
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
|
||||
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<%- end -%>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
||||
<p><%= t.size %> entries</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<%- end -%>
|
||||
<%- title.entries.each_with_index do |e, i| -%>
|
||||
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
|
||||
<a class="acard">
|
||||
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_display_name %>", "<%= e.encoded_display_name %>", '<%= e.title_id %>', '<%= e.id %>')">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
||||
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", """) %>"><%= e.display_name %></h3>
|
||||
<p><%= e.pages %> pages</p>
|
||||
</div>
|
||||
<a class="acard">
|
||||
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_display_name %>", "<%= e.encoded_display_name %>", '<%= e.title_id %>', '<%= e.id %>')">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
||||
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", """) %>"><%= e.display_name %></h3>
|
||||
<p><%= e.pages %> pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
@@ -74,14 +74,14 @@
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<div>
|
||||
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
|
||||
|
||||
<% if is_admin %>
|
||||
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||
<% end %>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
|
||||
|
||||
<% if is_admin %>
|
||||
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||
<% end %>
|
||||
</h3>
|
||||
</div>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
|
||||
</div>
|
||||
@@ -104,56 +104,56 @@
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<div>
|
||||
<h3 class="uk-modal-title break-word" id="modal-title">Edit</h3>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="uk-modal-title break-word">Edit</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="display-name">Display Name</label>
|
||||
<div class="uk-inline">
|
||||
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
||||
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Cover Image</label>
|
||||
<div class="uk-grid">
|
||||
<div class="uk-width-1-2@s">
|
||||
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
|
||||
<div>
|
||||
<span uk-icon="icon: cloud-upload"></span>
|
||||
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
|
||||
<div uk-form-custom>
|
||||
<input type="file" accept="image/jpeg, image/png">
|
||||
<span class="uk-link">selecting one</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="display-name">Display Name</label>
|
||||
<div class="uk-inline">
|
||||
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
||||
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Cover Image</label>
|
||||
<div class="uk-grid">
|
||||
<div class="uk-width-1-2@s">
|
||||
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
|
||||
<div>
|
||||
<span uk-icon="icon: cloud-upload"></span>
|
||||
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
|
||||
<div uk-form-custom>
|
||||
<input type="file" accept="image/jpeg, image/png">
|
||||
<span class="uk-link">selecting one</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
|
||||
</div>
|
||||
<div id="title-progress-control" hidden>
|
||||
<label class="uk-form-label">Progress</label>
|
||||
<p class="uk-margin-remove-vertical">
|
||||
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
|
||||
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
|
||||
</div>
|
||||
<div id="title-progress-control" hidden>
|
||||
<label class="uk-form-label">Progress</label>
|
||||
<p class="uk-margin-remove-vertical">
|
||||
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
|
||||
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="/js/dots.js"></script>
|
||||
<script src="/js/alert.js"></script>
|
||||
<script src="/js/title.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/sort-items.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/title.js"></script>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<form action="/admin/user/edit" method="post" accept-charset="utf-8">
|
||||
<form action="<%= base_url %>admin/user/edit" method="post" accept-charset="utf-8">
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="form-stacked-text">Username</label>
|
||||
<input class="uk-input" type="text" name="username"
|
||||
<%- if username -%>
|
||||
value=<%= username %>
|
||||
<%- end -%>
|
||||
>
|
||||
<input class="uk-input" type="text" name="username" <%- if username -%> value=<%= username %> <%- end -%>>
|
||||
</div>
|
||||
<%- if new_user -%>
|
||||
<div class="uk-margin">
|
||||
@@ -16,11 +12,7 @@
|
||||
<%- end -%>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
|
||||
<input class="uk-checkbox" type="checkbox" name="admin"
|
||||
<%- if admin == true -%>
|
||||
checked
|
||||
<%- end -%>
|
||||
>
|
||||
<input class="uk-checkbox" type="checkbox" name="admin" <%- if admin == true -%> checked <%- end -%>>
|
||||
</div>
|
||||
|
||||
<%- if !new_user -%>
|
||||
@@ -43,12 +35,12 @@
|
||||
var username;
|
||||
var error;
|
||||
<%- if !new_user -%>
|
||||
username = '/<%= username %>';
|
||||
username = '/<%= username %>';
|
||||
<%- end -%>
|
||||
<%- if error -%>
|
||||
error = '<%= error %>';
|
||||
<%- if error -%>
|
||||
error = '<%= error %>';
|
||||
<%- end -%>
|
||||
</script>
|
||||
<script src="/js/alert.js"></script>
|
||||
<script src="/js/user-edit.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/user-edit.js"></script>
|
||||
<% end %>
|
||||
@@ -8,24 +8,24 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<%- users.each do |u| -%>
|
||||
<tr>
|
||||
<td><%= u[0] %></td>
|
||||
<td><%= u[1] %></td>
|
||||
<td>
|
||||
<a href="/admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
|
||||
<%- if u[0] != username %>
|
||||
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
|
||||
<%- end %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><%= u[0] %></td>
|
||||
<td><%= u[1] %></td>
|
||||
<td>
|
||||
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
|
||||
<%- if u[0] != username %>
|
||||
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
|
||||
<%- end %>
|
||||
</td>
|
||||
</tr>
|
||||
<%- end -%>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a href="/admin/user/edit" class="uk-button uk-button-primary">New User</a>
|
||||
<a href="<%= base_url %>admin/user/edit" class="uk-button uk-button-primary">New User</a>
|
||||
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="/js/alert.js"></script>
|
||||
<script src="/js/user.js"></script>
|
||||
<% end %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/user.js"></script>
|
||||
<% end %>
|
||||
Reference in New Issue
Block a user