Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Ling
e65cd05ef1 Add the option to disable ellipsis when truncating 2020-05-15 09:50:35 +00:00
51 changed files with 706 additions and 1214 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,3 @@
# These are supported funding model platforms # These are supported funding model platforms
patreon: hkalexling patreon: hkalexling
ko_fi: hkalexling

View File

@@ -8,24 +8,19 @@ on:
jobs: jobs:
build: build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.34.0-alpine image: crystallang/crystal:0.34.0-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static run: apk add --no-cache yarn yaml sqlite-static
- name: Build - name: Build
run: make static run: make
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: mango
path: mango

1
.gitignore vendored
View File

@@ -8,4 +8,3 @@ yarn.lock
dist dist
mango mango
.env .env
*.md

View File

@@ -4,7 +4,7 @@ WORKDIR /Mango
COPY . . COPY . .
COPY package*.json . COPY package*.json .
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \ RUN apk add --no-cache yarn yaml sqlite-static \
&& make static && make static
FROM library/alpine FROM library/alpine

View File

@@ -14,7 +14,7 @@ static: uglify | libs
crystal build src/mango.cr --release --progress --static crystal build src/mango.cr --release --progress --static
libs: libs:
shards install --production shards install
run: run:
crystal run src/mango.cr --error-trace crystal run src/mango.cr --error-trace
@@ -25,7 +25,6 @@ test:
check: check:
crystal tool format --check crystal tool format --check
./bin/ameba ./bin/ameba
./dev/linewidth.sh
install: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango

View File

@@ -11,7 +11,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Multi-user support - Multi-user support
- Dark/light mode switch - Dark/light mode switch
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supports both `.zip` and `.cbz` formats
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader - Built-in [MangaDex](https://mangadex.org/) downloader
@@ -35,11 +35,11 @@ Simply download the pre-built binary file `mango` for the latest [release](https
### Docker (via Dockerhub) ### 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 ### Build from source
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 1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
2. Clone the repository 2. Clone the repository
3. `make && sudo make install` 3. `make && sudo make install`
4. Start Mango by running the command `mango` 4. Start Mango by running the command `mango`
@@ -50,21 +50,11 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.5.2 Mango e-manga server/reader. Version 0.3.0
Usage: -v, --version Show version
-h, --help Show help
mango [sub_command] [options] -c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
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 ### Config
@@ -74,20 +64,17 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
port: 9000 port: 9000
base_url: / library_path: /home/alex_ling/mango/library
library_path: ~/mango/library upload_path: /home/alex_ling/mango/uploads
db_path: ~/mango/mango.db db_path: /home/alex_ling/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
log_level: info log_level: info
upload_path: ~/mango/uploads
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
download_queue_db_path: ~/mango/queue.db download_queue_db_path: /home/alex_ling/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 - `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
@@ -95,7 +82,7 @@ mangadex:
### Library Structure ### Library Structure
You can organize your archive files in nested folders in the library directory. Here's an example: You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
``` ```
. .
@@ -107,8 +94,8 @@ You can organize your archive files in nested folders in the library directory.
└── Manga 2 └── Manga 2
   └── Vol. 1    └── Vol. 1
   └── Ch.1 - Ch.3    └── Ch.1 - Ch.3
   ├── 1.zip    ├── 1.zip
   ├── 2.zip    ├── 2.zip
   └── 3.zip    └── 3.zip
``` ```
@@ -140,6 +127,4 @@ Mobile UI:
## Contributors ## 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/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7) [![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)

View File

@@ -1,5 +0,0 @@
#!/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

View File

@@ -19,6 +19,7 @@
} }
.uk-card-title { .uk-card-title {
height: 3em; height: 3em;
overflow: hidden;
} }
.acard:hover { .acard:hover {
text-decoration: none; text-decoration: none;
@@ -36,8 +37,7 @@
word-wrap: break-word; word-wrap: break-word;
} }
.uk-logo > img { .uk-logo > img {
height: 90px; max-height: 90px;
width: 90px;
} }
.uk-search { .uk-search {
width: 100%; width: 100%;

View File

@@ -5,7 +5,7 @@ function scan() {
$('#scan-status > span').attr('hidden', ''); $('#scan-status > span').attr('hidden', '');
var color = $('#scan').css('color'); var color = $('#scan').css('color');
$('#scan').css('color', 'gray'); $('#scan').css('color', 'gray');
$.post(base_url + 'api/admin/scan', function (data) { $.post('/api/admin/scan', function (data) {
var ms = data.milliseconds; var ms = data.milliseconds;
var titles = data.titles; var titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms'); $('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');

View File

@@ -22,7 +22,7 @@ const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked'); globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
}; };
const remove = (id) => { const remove = (id) => {
var url = base_url + 'api/admin/mangadex/queue/delete'; var url = '/api/admin/mangadex/queue/delete';
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({id: id}); url += '?' + $.param({id: id});
console.log(url); console.log(url);
@@ -43,7 +43,7 @@ const remove = (id) => {
}); });
}; };
const refresh = (id) => { const refresh = (id) => {
var url = base_url + 'api/admin/mangadex/queue/retry'; var url = '/api/admin/mangadex/queue/retry';
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({id: id}); url += '?' + $.param({id: id});
console.log(url); console.log(url);
@@ -67,7 +67,7 @@ const toggle = () => {
$('#pause-resume-btn').attr('disabled', ''); $('#pause-resume-btn').attr('disabled', '');
const paused = $('#pause-resume-btn').text() === 'Resume download'; const paused = $('#pause-resume-btn').text() === 'Resume download';
const action = paused ? 'resume' : 'pause'; const action = paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`; const url = `/api/admin/mangadex/queue/${action}`;
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: url, url: url,
@@ -87,7 +87,7 @@ const load = () => {
console.log('fetching'); console.log('fetching');
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: base_url + 'api/admin/mangadex/queue', url: '/api/admin/mangadex/queue',
dataType: 'json' dataType: 'json'
}) })
.done(data => { .done(data => {

View File

@@ -33,7 +33,7 @@ const download = () => {
console.log(ids); console.log(ids);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: base_url + 'api/admin/mangadex/download', url: '/api/admin/mangadex/download',
data: JSON.stringify({chapters: chapters}), data: JSON.stringify({chapters: chapters}),
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
@@ -47,7 +47,7 @@ const download = () => {
const successCount = parseInt(data.success); const successCount = parseInt(data.success);
const failCount = parseInt(data.fail); const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads'; window.location.href = '/admin/downloads';
}); });
styleModal(); styleModal();
}) })
@@ -109,7 +109,7 @@ const search = () => {
return; return;
} }
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`) $.getJSON("/api/admin/mangadex/manga/" + int_id)
.done((data) => { .done((data) => {
if (data.error) { if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error); alert('danger', 'Failed to get manga info. Error: ' + data.error);
@@ -242,10 +242,7 @@ const buildTable = () => {
Object.entries(filters).forEach(([k, v]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return; if (v === 'All') return;
if (k === 'group') { if (k === 'group') {
chapters = chapters.filter(c => { chapters = chapters.filter(c => v in c.groups);
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return; return;
} }
if (k === 'lang') { if (k === 'lang') {
@@ -300,9 +297,3 @@ const buildTable = () => {
}); });
$('#selection-controls').removeAttr('hidden'); $('#selection-controls').removeAttr('hidden');
}; };
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};

View File

@@ -22,8 +22,8 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`); $('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`); $('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
$('#read-btn').click(function(){ $('#read-btn').click(function(){
updateProgress(titleID, entryID, pages); updateProgress(titleID, entryID, pages);
@@ -39,7 +39,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
} }
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `/api/progress/${tid}/${page}`
const query = $.param({entry: eid}); const query = $.param({entry: eid});
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
@@ -66,7 +66,7 @@ const renameSubmit = (name, eid) => {
} }
const query = $.param({ entry: eid }); const query = $.param({ entry: eid });
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; let url = `/api/admin/display_name/${titleId}/${name}`;
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
@@ -130,7 +130,7 @@ const setupUpload = (eid) => {
if (eid) if (eid)
queryObj['entry'] = eid; queryObj['entry'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`; const url = `/api/admin/upload/cover?${query}`;
console.log(url); console.log(url);
UIkit.upload('.upload-field', { UIkit.upload('.upload-field', {
url: url, url: url,

View File

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

View File

@@ -1,5 +1,5 @@
function remove(username) { function remove(username) {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) { $.post('/api/admin/user/delete/' + username, function(data) {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} }

View File

@@ -2,20 +2,12 @@ version: 1.0
shards: shards:
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: 0.12.1 version: 0.12.0
archive:
github: hkalexling/archive.cr
version: 0.2.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8 version: 0.9.8
clim:
github: at-grandpa/clim
version: 0.12.0
db: db:
github: crystal-lang/crystal-db github: crystal-lang/crystal-db
version: 0.9.0 version: 0.9.0

View File

@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.5.2 version: 0.3.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -19,9 +19,7 @@ dependencies:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
archive:
github: hkalexling/archive.cr development_dependencies:
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
clim:
github: at-grandpa/clim

View File

@@ -2,7 +2,7 @@ require "./spec_helper"
describe Config do describe Config do
it "creates config if it does not exist" 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 File.exists?(path).should be_true
end end
end end

View File

@@ -1,71 +0,0 @@
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

View File

@@ -1,6 +1,6 @@
require "spec" require "spec"
require "../src/context"
require "../src/server" require "../src/server"
require "../src/config"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@@ -37,15 +37,15 @@ end
def with_default_config def with_default_config
temp_config = get_tempfile "mango-test-config" temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path config = Config.load temp_config.path
config.set_current logger = Logger.new config.log_level
yield config, temp_config.path yield config, logger, temp_config.path
temp_config.delete temp_config.delete
end end
def with_storage def with_storage
with_default_config do with_default_config do |_, logger|
temp_db = get_tempfile "mango-test-db" temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, false storage = Storage.new temp_db.path, logger
clear = yield storage, temp_db.path clear = yield storage, temp_db.path
if clear == true if clear == true
temp_db.delete temp_db.delete
@@ -54,9 +54,9 @@ def with_storage
end end
def with_queue def with_queue
with_default_config do with_default_config do |_, logger|
temp_queue_db = get_tempfile "mango-test-queue-db" temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path queue = MangaDex::Queue.new temp_queue_db.path, logger
clear = yield queue, temp_queue_db.path clear = yield queue, temp_queue_db.path
if clear == true if clear == true
temp_queue_db.delete temp_queue_db.delete

View File

@@ -1,59 +0,0 @@
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

View File

@@ -4,7 +4,6 @@ class Config
include YAML::Serializable include YAML::Serializable
property port : Int32 = 9000 property port : Int32 = 9000
property base_url : String = "/"
property library_path : String = File.expand_path "~/mango/library", property library_path : String = File.expand_path "~/mango/library",
home: true home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
@@ -13,6 +12,7 @@ class Config
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
property disable_ellipsis_truncation : Bool = false
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -23,26 +23,13 @@ class Config
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true), 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?) def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.preprocess
config.fill_defaults config.fill_defaults
return config return config
end end
@@ -72,13 +59,4 @@ class Config
end end
{% end %} {% 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 end

21
src/context.cr Normal file
View File

@@ -0,0 +1,21 @@
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

View File

@@ -9,11 +9,9 @@ class AuthHandler < Kemal::Handler
def call(env) def call(env)
return call_next(env) if request_path_startswith env, ["/login", "/logout"] return call_next(env) if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find do |c| cookie = env.request.cookies.find { |c| c.name == "token" }
c.name == "token-#{Config.current.port}"
end
if cookie.nil? || !@storage.verify_token cookie.value if cookie.nil? || !@storage.verify_token cookie.value
return redirect env, "/login" return env.redirect "/login"
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] if request_path_startswith env, ["/admin", "/api/admin", "/download"]

View File

@@ -2,17 +2,20 @@ require "kemal"
require "../logger" require "../logger"
class LogHandler < Kemal::BaseLogHandler class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : Logger)
end
def call(env) def call(env)
elapsed_time = Time.measure { call_next env } elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \ msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}" " #{env.request.resource} #{elapsed_text}"
Logger.debug msg @logger.debug msg
env env
end end
def write(msg) def write(msg)
Logger.debug msg @logger.debug msg
end end
private def elapsed_text(elapsed) private def elapsed_text(elapsed)

View File

@@ -11,9 +11,7 @@ class UploadHandler < Kemal::Handler
return call_next env return call_next env
end end
ary = env.request.path.split(File::SEPARATOR).select do |part| ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
!part.empty?
end
ary[0] = @upload_dir ary[0] = @upload_dir
path = File.join ary path = File.join ary

View File

@@ -1,8 +1,8 @@
require "zip"
require "mime" require "mime"
require "json" require "json"
require "uri" require "uri"
require "./util" require "./util"
require "./archive"
struct Image struct Image
property data : Bytes property data : Bytes
@@ -25,7 +25,7 @@ class Entry
@title = File.basename path, File.extname path @title = File.basename path, File.extname path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes @size = (File.size path).humanize_bytes
file = ArchiveFile.new path file = Zip::File.new path
@pages = file.entries.count do |e| @pages = file.entries.count do |e|
["image/jpeg", "image/png"].includes? \ ["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
@@ -57,19 +57,18 @@ class Entry
end end
def cover_url def cover_url
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1" url = "/api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info| TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]? info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url url = info_url
end end
end end
url url
end end
def read_page(page_num) def read_page(page_num)
img = nil Zip::File.open @zip_path do |file|
ArchiveFile.open @zip_path do |file|
page = file.entries page = file.entries
.select { |e| .select { |e|
["image/jpeg", "image/png"].includes? \ ["image/jpeg", "image/png"].includes? \
@@ -79,13 +78,16 @@ class Entry
compare_alphanumerically a.filename, b.filename compare_alphanumerically a.filename, b.filename
} }
.[page_num - 1] .[page_num - 1]
data = file.read_entry page page.open do |io|
if data slice = Bytes.new page.uncompressed_size
img = Image.new data, MIME.from_filename(page.filename), page.filename, bytes_read = io.read_fully? slice
data.size unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),
page.filename, bytes_read
end end
end end
img
end end
end end
@@ -95,7 +97,7 @@ class Title
encoded_title : String, mtime : Time encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage, def initialize(@dir : String, @parent_id, storage,
@library : Library) @logger : Logger, @library : Library)
@id = storage.get_id @dir, true @id = storage.get_id @dir, true
@title = File.basename dir @title = File.basename dir
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@@ -107,22 +109,18 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, storage, library title = Title.new path, @id, storage, @logger, library
next if title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title @library.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
next next
end end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path if [".zip", ".cbz"].includes? File.extname path
unless File.readable? path zip_exception = validate_zip path
Logger.warn "File #{path} is not readable. Please make sure the " \ unless zip_exception.nil?
"file permission is configured correctly." @logger.warn "File #{path} is corrupted or is not a valid zip " \
next "archive. Ignoring it."
end @logger.debug "Zip error: #{zip_exception}"
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next next
end end
entry = Entry.new path, self, @id, storage entry = Entry.new path, self, @id, storage
@@ -233,14 +231,14 @@ class Title
end end
def cover_url def cover_url
url = "#{Config.current.base_url}img/icon.png" url = "img/icon.png"
if @entries.size > 0 if @entries.size > 0
url = @entries[0].cover_url url = @entries[0].cover_url
end end
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_url = info.cover_url info_url = info.cover_url
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url url = info_url
end end
end end
url url
@@ -369,19 +367,9 @@ end
class Library class Library
property dir : String, title_ids : Array(String), scan_interval : Int32, property dir : String, title_ids : Array(String), scan_interval : Int32,
storage : Storage, title_hash : Hash(String, Title) logger : Logger, storage : Storage, title_hash : Hash(String, Title)
def self.default : self def initialize(@dir, @scan_interval, @logger, @storage)
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 # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@@ -393,7 +381,7 @@ class Library
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds 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 sleep @scan_interval * 60
end end
end end
@@ -422,8 +410,8 @@ class Library
def scan def scan
unless Dir.exists? @dir unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \ @logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
@title_ids.clear @title_ids.clear
@@ -431,13 +419,13 @@ class Library
.select { |fn| !fn.starts_with? "." } .select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn } .map { |fn| File.join @dir, fn }
.select { |path| File.directory? path } .select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, self } .map { |path| Title.new path, "", @storage, @logger, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
end end
Logger.debug "Scan completed" @logger.debug "Scan completed"
end end
end end

View File

@@ -8,15 +8,7 @@ class Logger
@@severity : Log::Severity = :info @@severity : Log::Severity = :info
def self.default : self def initialize(level : String)
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
level = Config.current.log_level
{% begin %} {% begin %}
case level.downcase case level.downcase
when "off" when "off"
@@ -58,16 +50,9 @@ class Logger
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil @backend.write Log::Entry.new "", Log::Severity::None, msg, nil
end end
def self.log(msg)
default.log msg
end
{% for lvl in LEVELS %} {% for lvl in LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg } @log.{{lvl.id}} { msg }
end end
def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg
end
{% end %} {% end %}
end end

View File

@@ -1,7 +1,6 @@
require "http/client" require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename"
macro string_properties(names) macro string_properties(names)
{% for name in names %} {% for name in names %}
@@ -15,14 +14,6 @@ macro parse_strings_from_json(names)
{% end %} {% end %}
end end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex module MangaDex
class Chapter class Chapter
string_properties ["lang_code", "title", "volume", "chapter"] string_properties ["lang_code", "title", "volume", "chapter"]
@@ -73,20 +64,16 @@ module MangaDex
gname = obj["group_name#{s}"].as_s gname = obj["group_name#{s}"].as_s
@groups << {gid, gname} @groups << {gid, gname}
end end
@full_title = @title
rename_rule = Rename::Rule.new \ unless @chapter.empty?
Config.current.mangadex["chapter_rename_rule"].to_s @full_title = "Ch.#{@chapter} " + @full_title
@full_title = rename rename_rule end
unless @volume.empty?
@full_title = "Vol.#{@volume} " + @full_title
end
rescue e rescue e
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end 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 end
class Manga class Manga
@@ -124,23 +111,10 @@ module MangaDex
rescue e rescue e
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end end
class API class API
def self.default : self def initialize(@base_url = "https://mangadex.org/api/")
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 @lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row| CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0] @lang[row[1]] = row[0]

View File

@@ -1,6 +1,5 @@
require "./api" require "./api"
require "sqlite3" require "sqlite3"
require "zip"
module MangaDex module MangaDex
class PageJob class PageJob
@@ -80,21 +79,12 @@ module MangaDex
class Queue class Queue
property downloader : Downloader? property downloader : Downloader?
@path : String
def self.default : self def initialize(@path : String, @logger : Logger)
unless @@default dir = File.dirname path
@@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 unless Dir.exists? dir
Logger.info "The queue DB directory #{dir} does not exist. " \ @logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
@@ -111,7 +101,7 @@ module MangaDex
db.exec "create index if not exists status_idx " \ db.exec "create index if not exists status_idx " \
"on queue (status)" "on queue (status)"
rescue e rescue e
Logger.error "Error when checking tables in DB: #{e}" @logger.error "Error when checking tables in DB: #{e}"
raise e raise e
end end
end end
@@ -264,22 +254,11 @@ module MangaDex
class Downloader class Downloader
property stopped = false 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 @downloading = false
def self.default : self def initialize(@queue : Queue, @api : API, @library_path : String,
unless @@default @wait_seconds : Int32, @retries : Int32,
@@default = new @logger : Logger)
end
@@default.not_nil!
end
def initialize
@queue = Queue.default
@api = API.default
@queue.downloader = self @queue.downloader = self
spawn do spawn do
@@ -291,7 +270,7 @@ module MangaDex
next if job.nil? next if job.nil?
download job download job
rescue e rescue e
Logger.error e @logger.error e
end end
end end
end end
@@ -303,7 +282,7 @@ module MangaDex
begin begin
chapter = @api.get_chapter(job.id) chapter = @api.get_chapter(job.id)
rescue e rescue e
Logger.error e @logger.error e
@queue.set_status JobStatus::Error, job @queue.set_status JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@queue.add_message e.message.not_nil!, job @queue.add_message e.message.not_nil!, job
@@ -313,9 +292,7 @@ module MangaDex
end end
@queue.set_pages chapter.pages.size, job @queue.set_pages chapter.pages.size, job
lib_dir = @library_path lib_dir = @library_path
rename_rule = Rename::Rule.new \ manga_dir = File.join lib_dir, chapter.manga.title
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir unless File.exists? manga_dir
Dir.mkdir_p manga_dir Dir.mkdir_p manga_dir
end end
@@ -333,16 +310,16 @@ module MangaDex
ext = File.extname fn ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}" fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}" @logger.debug "Downloading #{url}"
loop do loop do
sleep @wait_seconds.seconds sleep @wait_seconds.seconds
download_page page_job download_page page_job
break if page_job.success || break if page_job.success ||
page_job.tries_remaning <= 0 page_job.tries_remaning <= 0
page_job.tries_remaning -= 1 page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \ @logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \ "Retrying... Remaining retries: " \
"#{page_job.tries_remaning}" "#{page_job.tries_remaning}"
end end
channel.send page_job channel.send page_job
@@ -353,8 +330,8 @@ module MangaDex
page_jobs = [] of PageJob page_jobs = [] of PageJob
chapter.pages.size.times do chapter.pages.size.times do
page_job = channel.receive page_job = channel.receive
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \ @logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}" "#{page_job.url}"
page_jobs << page_job page_jobs << page_job
if page_job.success if page_job.success
@queue.add_success job @queue.add_success job
@@ -362,16 +339,16 @@ module MangaDex
@queue.add_fail job @queue.add_fail job
msg = "Failed to download page #{page_job.url}" msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job @queue.add_message msg, job
Logger.error msg @logger.error msg
end end
end end
fail_count = page_jobs.count { |j| !j.success } fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \ @logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed" "#{fail_count}/#{page_jobs.size} failed"
writer.close writer.close
Logger.debug "cbz File created at #{zip_path}" @logger.debug "cbz File created at #{zip_path}"
zip_exception = validate_archive zip_path zip_exception = validate_zip zip_path
if !zip_exception.nil? if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \ @queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job "Error: #{zip_exception}", job
@@ -386,7 +363,7 @@ module MangaDex
end end
private def download_page(job : PageJob) private def download_page(job : PageJob)
Logger.debug "downloading #{job.url}" @logger.debug "downloading #{job.url}"
headers = HTTP::Headers{ headers = HTTP::Headers{
"User-agent" => "Mangadex.cr", "User-agent" => "Mangadex.cr",
} }
@@ -400,7 +377,7 @@ module MangaDex
end end
job.success = true job.success = true
rescue e rescue e
Logger.error e @logger.error e
job.success = false job.success = false
end end
end end

View File

@@ -1,103 +1,41 @@
require "./config"
require "./server" require "./server"
require "./context"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim"
MANGO_VERSION = "0.5.2" VERSION = "0.3.0"
macro common_option config_path = nil
option "-c PATH", "--config=PATH", type: String,
desc: "Path to the config file"
end
macro throw(msg) OptionParser.parse do |parser|
puts "ERROR: #{{{msg}}}" parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
puts
puts "Please see the `--help`."
exit 1
end
class CLI < Clim parser.on "-v", "--version", "Show version" do
main do puts "Version #{VERSION}"
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" exit
usage "mango [sub_command] [options]" end
help short: "-h" parser.on "-h", "--help", "Show help" do
version "Version #{MANGO_VERSION}", short: "-v" puts parser
common_option exit
run do |opts| end
Config.load(opts.config).set_current parser.on "-c PATH", "--config=PATH",
MangaDex::Downloader.default "Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
config_path = path
# 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
end end
CLI.start(ARGV) 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

View File

@@ -1,141 +0,0 @@
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

View File

@@ -1,7 +1,7 @@
require "./router" require "./router"
class AdminRouter < Router class AdminRouter < Router
def initialize def setup
get "/admin" do |env| get "/admin" do |env|
layout "admin" layout "admin"
end end
@@ -32,15 +32,29 @@ class AdminRouter < Router
# would not contain `admin` # would not contain `admin`
admin = !env.params.body["admin"]?.nil? 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 @context.storage.new_user username, password, admin
redirect env, "/admin/user" env.redirect "/admin/user"
rescue e rescue e
@context.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"error" => e.message}) query: hash_to_query({"error" => e.message})
redirect env, redirect_url.to_s env.redirect redirect_url.to_s
end end
post "/admin/user/edit/:original_username" do |env| post "/admin/user/edit/:original_username" do |env|
@@ -51,21 +65,38 @@ class AdminRouter < Router
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"] 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 \ @context.storage.update_user \
original_username, username, password, admin original_username, username, password, admin
redirect env, "/admin/user" env.redirect "/admin/user"
rescue e rescue e
@context.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \ query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message}) "admin" => admin, "error" => e.message})
redirect env, redirect_url.to_s env.redirect redirect_url.to_s
end end
get "/admin/downloads" do |env| get "/admin/downloads" do |env|
mangadex_base_url = Config.current.mangadex["base_url"] base_url = @context.config.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
end end

View File

@@ -3,7 +3,7 @@ require "../mangadex/*"
require "../upload" require "../upload"
class APIRouter < Router class APIRouter < Router
def initialize def setup
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -123,7 +123,7 @@ class APIRouter < Router
get "/api/admin/mangadex/manga/:id" do |env| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
id = env.params.url["id"] id = env.params.url["id"]
api = MangaDex::API.default api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
manga = api.get_manga id manga = api.get_manga id
send_json env, manga.to_info_json send_json env, manga.to_info_json
rescue e rescue e
@@ -230,7 +230,7 @@ class APIRouter < Router
end end
ext = File.extname filename ext = File.extname filename
upload = Upload.new Config.current.upload_path upload = Upload.new @context.config.upload_path, @context.logger
url = upload.path_to_url upload.save "img", ext, part.body url = upload.path_to_url upload.save "img", ext, part.body
if url.nil? if url.nil?

View File

@@ -1,22 +1,19 @@
require "./router" require "./router"
class MainRouter < Router class MainRouter < Router
def initialize def setup
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url
render "src/views/login.ecr" render "src/views/login.ecr"
end end
get "/logout" do |env| get "/logout" do |env|
begin begin
cookie = env.request.cookies.find do |c| cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
c.name == "token-#{Config.current.port}"
end.not_nil!
@context.storage.logout cookie.value @context.storage.logout cookie.value
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" @context.error "Error when attempting to log out: #{e}"
ensure ensure
redirect env, "/login" env.redirect "/login"
end end
end end
@@ -26,13 +23,12 @@ class MainRouter < Router
password = env.params.body["password"] password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil! token = @context.storage.verify_user(username, password).not_nil!
cookie = HTTP::Cookie.new "token-#{Config.current.port}", token cookie = HTTP::Cookie.new "token", token
cookie.path = Config.current.base_url
cookie.expires = Time.local.shift years: 1 cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie env.response.cookies << cookie
redirect env, "/" env.redirect "/"
rescue rescue
redirect env, "/login" env.redirect "/login"
end end
end end
@@ -41,6 +37,7 @@ class MainRouter < Router
titles = @context.library.titles titles = @context.library.titles
username = get_username env username = get_username env
percentage = titles.map &.load_percetage username percentage = titles.map &.load_percetage username
use_dotdotdot = !@context.config.disable_ellipsis_truncation
layout "index" layout "index"
rescue e rescue e
@context.error e @context.error e
@@ -55,6 +52,7 @@ class MainRouter < Router
percentage = title.entries.map { |e| percentage = title.entries.map { |e|
title.load_percetage username, e.title title.load_percetage username, e.title
} }
use_dotdotdot = !@context.config.disable_ellipsis_truncation
layout "title" layout "title"
rescue e rescue e
@context.error e @context.error e
@@ -63,7 +61,7 @@ class MainRouter < Router
end end
get "/download" do |env| get "/download" do |env|
mangadex_base_url = Config.current.mangadex["base_url"] base_url = @context.config.mangadex["base_url"]
layout "download" layout "download"
end end
end end

View File

@@ -1,7 +1,7 @@
require "./router" require "./router"
class ReaderRouter < Router class ReaderRouter < Router
def initialize def setup
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
@@ -15,7 +15,7 @@ class ReaderRouter < Router
# might not have actually read them # might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max page = [page - 2 * IMGS_PER_PAGE, 1].max
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}" env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
@@ -24,8 +24,6 @@ class ReaderRouter < Router
get "/reader/:title/:entry/:page" do |env| get "/reader/:title/:entry/:page" do |env|
begin begin
base_url = Config.current.base_url
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
@@ -37,20 +35,20 @@ class ReaderRouter < Router
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx| urls = pages.map { |idx|
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}" "/api/page/#{title.id}/#{entry.id}/#{idx}"
} }
reader_urls = pages.map { |idx| reader_urls = pages.map { |idx|
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}" "/reader/#{title.id}/#{entry.id}/#{idx}"
} }
next_page = page + IMGS_PER_PAGE next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}" exit_url = "/book/#{title.id}"
next_entry = title.next_entry entry next_entry = title.next_entry entry
unless next_page > entry.pages unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}" next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
end end
unless next_entry.nil? unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
end end
render "src/views/reader.ecr" render "src/views/reader.ecr"

View File

@@ -1,3 +1,6 @@
require "../context"
class Router class Router
@context : Context = Context.default def initialize(@context : Context)
end
end end

View File

@@ -1,38 +1,11 @@
require "kemal" require "kemal"
require "./library" require "./context"
require "./handlers/*" require "./handlers/*"
require "./util" require "./util"
require "./routes/*" 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 class Server
@context : Context = Context.default def initialize(@context : Context)
def initialize
error 403 do |env| error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}" message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message" layout "message"
@@ -41,23 +14,20 @@ class Server
message = "HTTP 404: Mango cannot find the page #{env.request.path}" message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message" layout "message"
end end
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
{% if flag?(:release) %} MainRouter.new(@context).setup
error 500 do |env| AdminRouter.new(@context).setup
message = "HTTP 500: Internal server error. Please try again later." ReaderRouter.new(@context).setup
layout "message" APIRouter.new(@context).setup
end
{% end %}
MainRouter.new
AdminRouter.new
ReaderRouter.new
APIRouter.new
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new @context.logger
add_handler AuthHandler.new @context.storage add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new Config.current.upload_path add_handler UploadHandler.new @context.config.upload_path
{% if flag?(:release) %} {% if flag?(:release) %}
# when building for relase, embed the static files in binary # when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files." @context.debug "We are in release mode. Using embedded static files."
@@ -71,7 +41,7 @@ class Server
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
Kemal.config.port = Config.current.port Kemal.config.port = @context.config.port
Kemal.run Kemal.run
end end
end end

View File

@@ -13,24 +13,14 @@ def verify_password(hash, pw)
end end
class Storage class Storage
@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, init_user = true)
@path = db_path || Config.current.db_path
dir = File.dirname @path
unless Dir.exists? dir unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \ @logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{path}" do |db|
begin begin
# We create the `ids` table first. even if the uses has an # We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only, # early version installed and has the `user` table only,
@@ -44,33 +34,23 @@ class Storage
"(username text, password text, token text, admin integer)" "(username text, password text, token text, admin integer)"
rescue e rescue e
unless e.message.not_nil!.ends_with? "already exists" unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}" @logger.fatal "Error when checking tables in DB: #{e}"
raise e raise e
end 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 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 username_idx on users (username)"
db.exec "create unique index token_idx on users (token)" db.exec "create unique index token_idx on users (token)"
random_pw = random_str
init_admin if init_user 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 end
end 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) def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
@@ -78,18 +58,18 @@ class Storage
"users where username = (?)", "users where username = (?)",
username, as: {String, String?} username, as: {String, String?}
unless verify_password hash, password unless verify_password hash, password
Logger.debug "Password does not match the hash" @logger.debug "Password does not match the hash"
return nil return nil
end end
Logger.debug "User #{username} verified" @logger.debug "User #{username} verified"
return token if token return token if token
token = random_str token = random_str
Logger.debug "Updating token for #{username}" @logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)", db.exec "update users set token = (?) where username = (?)",
token, username token, username
return token return token
rescue e rescue e
Logger.error "Error when verifying user #{username}: #{e}" @logger.error "Error when verifying user #{username}: #{e}"
return nil return nil
end end
end end
@@ -102,7 +82,7 @@ class Storage
username = db.query_one "select username from users where " \ username = db.query_one "select username from users where " \
"token = (?)", token, as: String "token = (?)", token, as: String
rescue e rescue e
Logger.debug "Unable to verify token" @logger.debug "Unable to verify token"
end end
end end
username username
@@ -115,7 +95,7 @@ class Storage
is_admin = db.query_one "select admin from users where " \ is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool "token = (?)", token, as: Bool
rescue e rescue e
Logger.debug "Unable to verify user as admin" @logger.debug "Unable to verify user as admin"
end end
end end
is_admin is_admin
@@ -134,8 +114,6 @@ class Storage
end end
def new_user(username, password, admin) def new_user(username, password, admin)
validate_username username
validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
hash = hash_password password hash = hash_password password
@@ -146,10 +124,8 @@ class Storage
def update_user(original_username, username, password, admin) def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username
validate_password password unless password.empty?
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
if password.empty? if password.size == 0
db.exec "update users set username = (?), admin = (?) " \ db.exec "update users set username = (?), admin = (?) " \
"where username = (?)", "where username = (?)",
username, admin, original_username username, admin, original_username

View File

@@ -1,10 +1,10 @@
require "./util" require "./util"
class Upload class Upload
def initialize(@dir : String) def initialize(@dir : String, @logger : Logger)
unless Dir.exists? @dir unless Dir.exists? @dir
Logger.info "The uploads directory #{@dir} does not exist. " \ @logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
end end
@@ -19,7 +19,7 @@ class Upload
file_path = File.join full_dir, filename file_path = File.join full_dir, filename
unless Dir.exists? full_dir unless Dir.exists? full_dir
Logger.debug "creating directory #{full_dir}" @logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir Dir.mkdir_p full_dir
end end
@@ -50,7 +50,7 @@ class Upload
end end
if ary.empty? 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 return
end end

View File

@@ -4,11 +4,8 @@ IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
macro layout(name) macro layout(name)
base_url = Config.current.base_url
begin begin
cookie = env.request.cookies.find do |c| cookie = env.request.cookies.find { |c| c.name == "token" }
c.name == "token-#{Config.current.port}"
end
is_admin = false is_admin = false
unless cookie.nil? unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value is_admin = @context.storage.verify_admin cookie.value
@@ -28,9 +25,7 @@ end
macro get_username(env) macro get_username(env)
# if the request gets here, it has gone through the auth handler, and # 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 # we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find do |c| cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
c.name == "token-#{Config.current.port}"
end.not_nil!
(@context.storage.verify_token cookie.value).not_nil! (@context.storage.verify_token cookie.value).not_nil!
end end
@@ -89,9 +84,12 @@ def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b) compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end end
def validate_archive(path : String) : Exception? # When downloading from MangaDex, the zip/cbz file would not be valid
file = ArchiveFile.new path # before the download is completed. If we scan the zip file,
file.check # 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
file.close file.close
return return
rescue e rescue e
@@ -101,27 +99,3 @@ end
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
end 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

View File

@@ -1,5 +1,5 @@
<ul class="uk-list uk-list-large uk-list-divider"> <ul class="uk-list uk-list-large uk-list-divider">
<li data-url="<%= base_url %>admin/user">User Managerment</li> <li data-url="/admin/user">User Managerment</li>
<li onclick="if(!scanning){scan()}"> <li onclick="if(!scanning){scan()}">
<span id="scan">Scan Library Files</span> <span id="scan">Scan Library Files</span>
<span id="scan-status" class="uk-align-right"> <span id="scan-status" class="uk-align-right">
@@ -7,12 +7,12 @@
<span hidden></span> <span hidden></span>
</span> </span>
</li> </li>
<li data-url="<%= base_url %>admin/downloads">Download Manager</li> <li data-url="/admin/downloads">Download Manager</li>
</ul> </ul>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a> <a class="uk-button uk-button-danger" href="/logout">Log Out</a>
<% content_for "script" do %> <% content_for "script" do %>
<script src="<%= base_url %>js/admin.js"></script> <script src="/js/admin.js"></script>
<% end %> <% end %>

View File

@@ -1,32 +1,32 @@
<div class="uk-margin"> <div class="uk-margin">
<div id="actions" 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="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="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="load()">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button> <button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
</div> </div>
<div id="config" class="uk-margin"> <div id="config" class="uk-margin">
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label> <label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
</div> </div>
</div> </div>
<table class="uk-table uk-table-striped uk-overflow-auto"> <table class="uk-table uk-table-striped uk-overflow-auto">
<thead> <thead>
<tr> <tr>
<th>Chapter</th> <th>Chapter</th>
<th>Manga</th> <th>Manga</th>
<th>Progress</th> <th>Progress</th>
<th>Time</th> <th>Time</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
</table> </table>
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); var baseURL = "<%= base_url %>".replace(/\/$/, "");
</script> </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/moment.js/2.24.0/moment.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="/js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="/js/download-manager.js"></script>
<% end %> <% end %>

View File

@@ -1,83 +1,83 @@
<h2 class=uk-title>Download from MangaDex</h2> <h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4"> <div class="uk-width-3-4">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL"> <input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
</div> </div>
<div class="uk-width-1-4"> <div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div> <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> <button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
</div> </div>
</div> </div>
<div class"uk-grid-small" uk-grid hidden id="manga-details"> <div class"uk-grid-small" uk-grid hidden id="manga-details">
<div class="uk-width-1-4@s"> <div class="uk-width-1-4@s">
<img id="cover"> <img id="cover">
</div> </div>
<div class="uk-width-1-4@s"> <div class="uk-width-1-4@s">
<p id="title"></p> <p id="title"></p>
<p id="artist"></p> <p id="artist"></p>
<p id="author"></p> <p id="author"></p>
</div> </div>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden> <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-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p> <p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="lang-select">Language</label> <label class="uk-form-label" for="lang-select">Language</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select"> <select class="uk-select filter-field" id="lang-select">
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="group-select">Group</label> <label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select filter-field" id="group-select"> <select class="uk-select filter-field" id="group-select">
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label> <label class="uk-form-label" for="volume-range">Volume</label>
<div class="uk-form-controls"> <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."> <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> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="chapter-range">Chapter</label> <label class="uk-form-label" for="chapter-range">Chapter</label>
<div class="uk-form-controls"> <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."> <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> </div>
</div> </div>
<div id="selection-controls" class="uk-margin" hidden> <div id="selection-controls" class="uk-margin" hidden>
<div class="uk-margin"> <div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button> <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-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</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 id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</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> <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> </div>
<p id="filter-notification" hidden></p> <p id="filter-notification" hidden></p>
<table class="uk-table uk-table-striped uk-overflow-auto" hidden> <table class="uk-table uk-table-striped uk-overflow-auto" hidden>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Title</th> <th>Title</th>
<th>Language</th> <th>Language</th>
<th>Group</th> <th>Group</th>
<th>Volume</th> <th>Volume</th>
<th>Chapter</th> <th>Chapter</th>
<th>Timestamp</th> <th>Timestamp</th>
</tr> </tr>
</thead> </thead>
</table> </table>
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); var baseURL = "<%= base_url %>".replace(/\/$/, "");
</script> </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/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="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="/js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script> <script src="/js/download.js"></script>
<% end %> <% end %>

View File

@@ -1,49 +1,51 @@
<h2 class=uk-title>Library</h2> <h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p> <p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s"> <div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default"> <form class="uk-search uk-search-default">
<span uk-search-icon></span> <span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search"> <input class="uk-search-input" type="search" placeholder="Search">
</form> </form>
</div> </div>
<div class="uk-margin-bottom uk-width-1-4@s"> <div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal"> <div class="uk-form-horizontal">
<select class="uk-select" id="sort-select"> <select class="uk-select" id="sort-select">
<option id="name-up">â–˛ Name</option> <option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option> <option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option> <option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option> <option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option> <option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option> <option id="progress-down">â–Ľ Progress</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <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| -%> <%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>"> <div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="<%= base_url %>book/<%= t.id %>"> <a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img> <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" uk-tooltip="<%= t.display_name.gsub("\"", "&quot;") %>" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div> </div>
<div class="uk-card-body"> </a>
<%- if t.entries.size > 0 -%> </div>
<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("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%> <%- end -%>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <% if use_dotdotdot %>
<script src="<%= base_url %>js/dots.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="/js/dots.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <% end %>
<% end %> <script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>

View File

@@ -1,79 +1,75 @@
<!DOCTYPE html> <!DOCTYPE html>
<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>
<head> <body>
<meta charset="utf-8"> <div class="uk-offcanvas-content">
<meta name="X-UA-Compatible" content="IE=edge"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<title>Mango</title> <div id="mobile-nav" uk-offcanvas="overlay: true">
<meta name="description" content="Mango - Manga Server and Web Reader"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<meta name="viewport" content="width=device-width, initial-scale=1"> <ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" /> <li><a href="/">Home</a></li>
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <% if is_admin %>
<link rel="icon" href="<%= base_url %>favicon.ico"> <li><a href="/admin">Admin</a></li>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script> <li><a href="/download">Download</a></li>
<script defer src="<%= base_url %>js/solid.min.js"></script> <% end %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <hr uk-divider>
<script src="<%= base_url %>js/theme.js"></script> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
</head> <li><a href="/logout">Logout</a></li>
</ul>
<body> </div>
<div class="uk-offcanvas-content"> </div>
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> </div>
<div id="mobile-nav" uk-offcanvas="overlay: true"> </div>
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-position-top">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<li><a href="<%= base_url %>">Home</a></li> <div class="uk-navbar-left uk-hidden@s">
<% if is_admin %> <div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
<li><a href="<%= base_url %>admin">Admin</a></li> </div>
<li><a href="<%= base_url %>download">Download</a></li> <div class="uk-navbar-left uk-visible@s">
<% end %> <a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
<hr uk-divider> <ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <li><a href="/">Home</a></li>
<li><a href="<%= base_url %>logout">Logout</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>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="uk-section uk-section-small">
<div class="uk-position-top"> </div>
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> <div class="uk-section uk-section-small">
<div class="uk-navbar-left uk-hidden@s"> <div class="uk-container uk-container-small">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div> <div id="alert"></div>
</div> <%= content %>
<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>
</div> </div>
</div> <script>
<div class="uk-section uk-section-small"> setTheme(getTheme());
</div> </script>
<div class="uk-section uk-section-small"> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<div class="uk-container uk-container-small"> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<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> </html>

View File

@@ -1,46 +1,43 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge"> <meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title> <title>Mango</title>
<meta name="description" content="Mango Manga Server"> <meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> <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" /> <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="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script> <script src="/js/theme.js"></script>
</head> </head>
<body> <body>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport=""> <div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1"> <div class="uk-width-1-1">
<div class="uk-container"> <div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid=""> <div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
<div class="uk-width-1-1@m"> <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"> <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> <h3 class="uk-card-title uk-text-center">Log In</h3>
<form action="<%= base_url %>login" method="post"> <form action="/login" method="post">
<div class="uk-margin"> <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 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 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>
</div>
</div> </div>
</div> <script>
<script> setTheme(getTheme());
setTheme(getTheme()); </script>
</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.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body> </body>
</html>
</html>

View File

@@ -1 +1 @@
<p class="uk-text-lead uk-text-center"><%= message %></p> <p class="uk-text-lead uk-text-center"><%= message %></p>

View File

@@ -1,70 +1,65 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="reader-bg"> <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>
<head> <body>
<meta charset="utf-8"> <script src="/js/theme.js"></script>
<meta name="X-UA-Compatible" content="IE=edge"> <div class="uk-section uk-section-default uk-section-small reader-bg">
<title>Mango</title> <div class="uk-container uk-container-small">
<meta name="description" content="Mango Manga Server"> <%- urls.each_with_index do |url, i| -%>
<meta name="viewport" content="width=device-width, initial-scale=1"> <img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" /> <%- end -%>
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <%- if next_url -%>
</head> <a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
<body> </div>
<script src="<%= base_url %>js/theme.js"></script> <%- if next_entry_url -%>
<div class="uk-section uk-section-default uk-section-small reader-bg"> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<div class="uk-container uk-container-small"> <%- else -%>
<%- urls.each_with_index do |url, i| -%> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<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 -%> <%- end -%>
</div> </div>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div id="hidden" hidden></div> <div id="hidden" hidden></div>
<div id="modal-sections" class="uk-flex-top" uk-modal> <div id="modal-sections" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<h3 class="uk-modal-title">Options</h3> <h3 class="uk-modal-title">Options</h3>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<p id="progress-label"></p>
</div> </div>
<div class="uk-margin"> <div class="uk-modal-body">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label> <div class="uk-margin">
<div class="uk-form-controls"> <p id="progress-label"></p>
<select id="page-select" class="uk-select"> </div>
<%- (1..entry.pages).each do |p| -%> <div class="uk-margin">
<option value="<%= p %>"><%= p %></option> <label class="uk-form-label" for="form-stacked-select">Jump to page</label>
<%- end -%> <div class="uk-form-controls">
</select> <select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
</select>
</div>
</div> </div>
</div> </div>
</div> <div class="uk-modal-footer uk-text-right">
<div class="uk-modal-footer uk-text-right"> <button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button> </div>
</div> </div>
</div> </div>
</div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
const base_url = "<%= base_url %>" <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</script> <script src="//cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="/js/reader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script> </body>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script> </html>
<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>

View File

@@ -1,72 +1,72 @@
<div> <div>
<h2 class=uk-title><span><%= title.display_name %></span> <h2 class=uk-title><span><%= title.display_name %></span>
&nbsp; &nbsp;
<% if is_admin %> <% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a> <a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %> <% end %>
</h2> </h2>
</div> </div>
<ul class="uk-breadcrumb"> <ul class="uk-breadcrumb">
<li><a href="<%= base_url %>">Library</a></li> <li><a href="/">Library</a></li>
<%- title.parents.each do |t| -%> <%- title.parents.each do |t| -%>
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li> <li><a href="/book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%> <%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li> <li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul> </ul>
<p class="uk-text-meta"><%= title.size %> entries found</p> <p class="uk-text-meta"><%= title.size %> entries found</p>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s"> <div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default"> <form class="uk-search uk-search-default">
<span uk-search-icon></span> <span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search"> <input class="uk-search-input" type="search" placeholder="Search">
</form> </form>
</div> </div>
<div class="uk-margin-bottom uk-width-1-4@s"> <div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal"> <div class="uk-form-horizontal">
<select class="uk-select" id="sort-select"> <select class="uk-select" id="sort-select">
<option id="auto-up">â–˛ Auto</option> <option id="auto-up">â–˛ Auto</option>
<option id="auto-down">â–Ľ Auto</option> <option id="auto-down">â–Ľ Auto</option>
<option id="name-up">â–˛ Name</option> <option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option> <option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option> <option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option> <option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option> <option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option> <option id="progress-down">â–Ľ Progress</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <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| -%> <%- title.titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0"> <div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
<a class="acard" href="<%= base_url %>book/<%= t.id %>"> <a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img> <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" uk-tooltip="<%= t.display_name.gsub("\"", "&quot;") %>" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div> </div>
<div class="uk-card-body"> </a>
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3> </div>
<p><%= t.size %> entries</p> <%- end -%>
</div>
</div>
</a>
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%> <%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>"> <div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
<a class="acard"> <a class="acard">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')"> <div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img> <img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
</div> </div>
<div class="uk-card-body"> <div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div> <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("\"", "&quot;") %>"><%= e.display_name %></h3> <h3 class="uk-card-title break-word" uk-tooltip="<%= e.display_name.gsub("\"", "&quot;") %>" data-title="<%= e.display_name.gsub("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p> <p><%= e.pages %> pages</p>
</div> </div>
</div> </div>
</a> </a>
</div> </div>
<%- end -%> <%- end -%>
</div> </div>
@@ -74,14 +74,14 @@
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<div> <div>
<h3 class="uk-modal-title break-word" id="modal-title"><span></span> <h3 class="uk-modal-title break-word" id="modal-title"><span></span>
&nbsp; &nbsp;
<% if is_admin %> <% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a> <a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %> <% end %>
</h3> </h3>
</div> </div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p> <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> <p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div> </div>
@@ -104,56 +104,58 @@
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<div> <div>
<h3 class="uk-modal-title break-word">Edit</h3> <h3 class="uk-modal-title break-word" id="modal-title">Edit</h3>
</div> </div>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label> <label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline"> <div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a> <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"> <input class="uk-input" type="text" name="display-name" id="display-name-field">
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label">Cover Image</label> <label class="uk-form-label">Cover Image</label>
<div class="uk-grid"> <div class="uk-grid">
<div class="uk-width-1-2@s"> <div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img> <img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div> </div>
<div class="uk-width-1-2@s"> <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 id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div> <div>
<span uk-icon="icon: cloud-upload"></span> <span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span> <span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom> <div uk-form-custom>
<input type="file" accept="image/jpeg, image/png"> <input type="file" accept="image/jpeg, image/png">
<span class="uk-link">selecting one</span> <span class="uk-link">selecting one</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress> <progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div> </div>
<div id="title-progress-control" hidden> <div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label> <label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical"> <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="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> <button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <% if use_dotdotdot %>
<script src="<%= base_url %>js/dots.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="/js/dots.js"></script>
<script src="<%= base_url %>js/title.js"></script> <% end %>
<script src="<%= base_url %>js/search.js"></script> <script src="/js/alert.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="/js/title.js"></script>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %> <% end %>

View File

@@ -1,8 +1,12 @@
<form action="<%= base_url %>admin/user/edit" method="post" accept-charset="utf-8"> <form action="/admin/user/edit" method="post" accept-charset="utf-8">
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Username</label> <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> </div>
<%- if new_user -%> <%- if new_user -%>
<div class="uk-margin"> <div class="uk-margin">
@@ -12,7 +16,11 @@
<%- end -%> <%- end -%>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Admin Access</label> <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> </div>
<%- if !new_user -%> <%- if !new_user -%>
@@ -35,12 +43,12 @@
var username; var username;
var error; var error;
<%- if !new_user -%> <%- if !new_user -%>
username = '/<%= username %>'; username = '/<%= username %>';
<%- end -%> <%- end -%>
<%- if error -%> <%- if error -%>
error = '<%= error %>'; error = '<%= error %>';
<%- end -%> <%- end -%>
</script> </script>
<script src="<%= base_url %>js/alert.js"></script> <script src="/js/alert.js"></script>
<script src="<%= base_url %>js/user-edit.js"></script> <script src="/js/user-edit.js"></script>
<% end %> <% end %>

View File

@@ -8,24 +8,24 @@
</thead> </thead>
<tbody> <tbody>
<%- users.each do |u| -%> <%- users.each do |u| -%>
<tr> <tr>
<td><%= u[0] %></td> <td><%= u[0] %></td>
<td><%= u[1] %></td> <td><%= u[1] %></td>
<td> <td>
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a> <a href="/admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
<%- if u[0] != username %> <%- if u[0] != username %>
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a> <a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
<%- end %> <%- end %>
</td> </td>
</tr> </tr>
<%- end -%> <%- end -%>
</tbody> </tbody>
</table> </table>
<a href="<%= base_url %>admin/user/edit" class="uk-button uk-button-primary">New User</a> <a href="/admin/user/edit" class="uk-button uk-button-primary">New User</a>
<% content_for "script" do %> <% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script> <script src="/js/alert.js"></script>
<script src="<%= base_url %>js/user.js"></script> <script src="/js/user.js"></script>
<% end %> <% end %>