Compare commits

..

58 Commits

Author SHA1 Message Date
Alex Ling
44336c546a Bump version to v0.18.3 2021-01-12 10:14:12 +00:00
Alex Ling
a4c6e6611c Try WSS first, and fallback to WS (#144) 2021-01-12 10:13:06 +00:00
Alex Ling
0b457a2797 Merge branch 'master' of https://github.com/hkalexling/Mango 2021-01-11 15:37:34 +00:00
Alex Ling
653751bede Merge branch 'dev' 2021-01-11 15:37:06 +00:00
Alex Ling
a02bf4a81e Bump version to v0.18.2 2021-01-11 15:22:51 +00:00
Alex Ling
5271d12f4c Respect base URL in WS connections 2021-01-11 15:05:58 +00:00
Alex Ling
c2e2f0b9b3 Merge pull request #143 from hkalexling/all-contributors/add-h45h74x
docs: add h45h74x as a contributor
2021-01-11 19:32:47 +08:00
allcontributors[bot]
72d319902e docs: update .all-contributorsrc [skip ci] 2021-01-11 11:31:21 +00:00
allcontributors[bot]
bbd0fd68cb docs: update README.md [skip ci] 2021-01-11 11:31:20 +00:00
Alex Ling
0fb1e1598d Remove sourcerer.io HoF and use all-contributors
[skip ci]
RIP sourcerer.io https://github.com/sourcerer-io/sourcerer-app/issues/632
2021-01-11 11:28:30 +00:00
Alex Ling
4645582f5d Bump version to v0.18.1 2021-01-11 05:29:28 +00:00
Alex Ling
ac9c51dd33 Remove non-existing #root from css selectors (#142) 2021-01-11 05:28:44 +00:00
Alex Ling
f51d27860a Validate input index before flipping page 2021-01-09 15:49:34 +00:00
Alex Ling
4a7439a1ea Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-09 06:40:49 +00:00
Alex Ling
00e19399d7 Check login is disabled before accessing default username 2021-01-09 06:35:26 +00:00
Alex Ling
cb723acef7 Update config in README 2021-01-09 06:35:11 +00:00
Alex Ling
794bed12bd Merge pull request #139 from h45h74x/feature/plugin-helper-function-post
Added post helper function
2021-01-09 14:30:52 +08:00
Simon
bae8220e75 Added post helper function 2021-01-08 21:17:58 +01:00
Alex Ling
0cc5e1626b Fix broken buttons on download manager page 2021-01-08 11:38:51 +00:00
Alex Ling
da0ca665a6 Mark entry as read when exiting reader at the end 2021-01-08 11:38:25 +00:00
Alex Ling
a91cf21aa9 Bump version to v0.18.0 2021-01-07 16:27:22 +00:00
Alex Ling
39b2636711 Sort tags in title 2021-01-07 16:21:23 +00:00
Alex Ling
2618d8412b Update the API doc to include margin in dimensions 2021-01-07 16:06:43 +00:00
Alex Ling
445ebdf357 Merge pull request #136 from h45h74x/feature/adjustable-page-gaps
Feature/adjustable page gaps
2021-01-07 01:11:34 +08:00
Simon
60134dc364 Formatting 2021-01-06 17:44:02 +01:00
Simon
aa70752244 Moved margin value to the dimensions API 2021-01-06 17:30:55 +01:00
Simon
0f39535097 Added new entry in example config 2021-01-06 15:28:09 +01:00
Simon
e086bec9da Added adjustable page gaps via config 2021-01-06 15:27:48 +01:00
Alex Ling
dcdcf29114 Sort tags on the tags page 2021-01-05 07:34:31 +00:00
Alex Ling
c5c73ddff3 Rewrite download-manager.js 2021-01-01 09:19:16 +00:00
Alex Ling
f18ee4284f Rewrite admin.js with Alpine component 2021-01-01 09:04:53 +00:00
Alex Ling
0fbc11386e Fix broken "Exit Reader" button 2021-01-01 09:04:18 +00:00
Alex Ling
a68282b4bf Rewrite reader.js with a reusable alpine function 2020-12-31 16:21:00 +00:00
Alex Ling
e64908ad06 Remove the outdated styleModal call 2020-12-31 14:08:14 +00:00
Alex Ling
af0913df64 Dynamic HTML title 2020-12-31 14:08:14 +00:00
Alex Ling
5685dd1cc5 Use tallboy to draw CLI table 2020-12-30 16:44:23 +00:00
Alex Ling
af2fd2a66a Remove the Context and Router classes 2020-12-30 15:58:51 +00:00
Alex Ling
db2a51a26b Clean up library classes 2020-12-30 15:23:38 +00:00
Alex Ling
cf930418cb Update rename spec 2020-12-30 12:53:48 +00:00
Alex Ling
911848ad11 Merge branch 'feature/tagging' into dev 2020-12-30 11:15:44 +00:00
Alex Ling
93f745aecb Only admins can add or delete tags 2020-12-30 11:13:43 +00:00
Alex Ling
981a1f0226 Add /tags to nav bar 2020-12-30 11:13:43 +00:00
Alex Ling
8188456788 Finish tagging 2020-12-30 11:13:43 +00:00
Alex Ling
1eace2c64c Add the /tags/:tag page 2020-12-30 11:13:43 +00:00
Alex Ling
c6ee5409f8 Trim input tag 2020-12-30 11:13:43 +00:00
Alex Ling
b05ed57762 Add API endpoints for tags 2020-12-30 11:13:43 +00:00
Alex Ling
0f1d1099f6 Add unique constraint to tags and error handling 2020-12-30 11:13:43 +00:00
Alex Ling
40a24f4247 Add tags to the web UI 2020-12-30 11:13:43 +00:00
Alex Ling
a6862e86d4 Update alpine 2020-12-30 11:13:43 +00:00
Alex Ling
bfc1b697bd Add tag related methods for Title 2020-12-30 11:13:43 +00:00
Alex Ling
276f62cb76 Update DB for tags 2020-12-30 11:13:43 +00:00
Alex Ling
45a81ad5f6 Display the entries and sub-titles count 2020-12-30 11:13:43 +00:00
Alex Ling
ce88acb9e5 Simplify the request_path_startswith helper method 2020-12-30 11:13:43 +00:00
Alex Ling
bd34b803f1 Tokens take precedence over default user setting 2020-12-30 11:13:43 +00:00
Alex Ling
2559f65f35 Display the entries and sub-titles count 2020-12-29 04:33:55 +00:00
Alex Ling
93c21ea659 Simplify the request_path_startswith helper method 2020-12-28 16:29:29 +00:00
Alex Ling
85ad38c321 Allow disable login 2020-12-28 16:13:51 +00:00
Alex Ling
b6a204f5bd Escape illegal filename characters in Windows 2020-12-28 15:20:09 +00:00
41 changed files with 1227 additions and 728 deletions

102
.all-contributorsrc Normal file
View File

@@ -0,0 +1,102 @@
{
"projectName": "Mango",
"projectOwner": "hkalexling",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "none",
"contributors": [
{
"login": "hkalexling",
"name": "Alex Ling",
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
"profile": "https://github.com/hkalexling/",
"contributions": [
"code",
"doc",
"infra"
]
},
{
"login": "jaredlt",
"name": "jaredlt",
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
"profile": "https://github.com/jaredlt",
"contributions": [
"code",
"ideas",
"design"
]
},
{
"login": "shincurry",
"name": "ココロ",
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
"profile": "https://windisco.com/",
"contributions": [
"infra"
]
},
{
"login": "noirscape",
"name": "Valentijn",
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
"profile": "https://catgirlsin.space/",
"contributions": [
"infra"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc",
"ideas"
]
},
{
"login": "XavierSchiller",
"name": "Xavier",
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
"profile": "https://github.com/XavierSchiller",
"contributions": [
"infra"
]
},
{
"login": "WROIATE",
"name": "Jarao",
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
"profile": "https://github.com/WROIATE",
"contributions": [
"infra"
]
},
{
"login": "Leeingnyo",
"name": "이인용",
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
"profile": "https://github.com/Leeingnyo",
"contributions": [
"code"
]
},
{
"login": "h45h74x",
"name": "Simon",
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
"profile": "http://h45h74x.eu.org",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}

View File

@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.17.1
Mango - Manga Server and Web Reader. Version 0.18.3
Usage:
@@ -87,18 +87,22 @@ log_level: info
upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
mangadex:
base_url: https://mangadex.org
api_url: https://mangadex.org/api
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: /home/alex_ling/mango/queue.db
download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}'
```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
### Library Structure
@@ -153,5 +157,26 @@ Mobile UI:
## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
</tr>
</table>
[![](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)
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->

View File

@@ -7,6 +7,7 @@
"license": "MIT",
"devDependencies": {
"@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1",

View File

@@ -31,14 +31,6 @@
cursor: pointer;
}
.uk-list li:not(.nopointer) {
cursor: pointer;
}
#scan-status {
cursor: auto;
}
.reader-bg {
background-color: black;
}

View File

@@ -1,68 +1,55 @@
$(() => {
const setting = loadThemeSetting();
$('#theme-select').val(capitalize(setting));
$('#theme-select').change((e) => {
const newSetting = $(e.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting);
setTheme();
});
const component = () => {
return {
progress: 1.0,
generating: false,
scanning: false,
scanTitles: 0,
scanMs: -1,
themeSetting: '',
getProgress();
setInterval(getProgress, 5000);
});
init() {
this.getProgress();
setInterval(() => {
this.getProgress();
}, 5000);
/**
* Capitalize String
*
* @function capitalize
* @param {string} str - The string to be capitalized
* @return {string} The capitalized string
*/
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
const setting = loadThemeSetting();
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
},
themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting);
setTheme();
},
scan() {
if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
};
/**
* Get the thumbnail generation progress from the API
*
* @function getProgress
*/
const getProgress = () => {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
setProp('progress', data.progress);
const generating = data.progress > 0
setProp('generating', generating);
});
};
/**
* Trigger the thumbnail generation
*
* @function generateThumbnails
*/
const generateThumbnails = () => {
setProp('generating', true);
setProp('progress', 0.0);
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(getProgress);
};
/**
* Trigger the scan
*
* @function scan
*/
const scan = () => {
setProp('scanning', true);
setProp('scanMs', -1);
setProp('scanTitles', 0);
$.post(`${base_url}api/admin/scan`)
.then(data => {
setProp('scanMs', data.milliseconds);
setProp('scanTitles', data.titles);
})
.always(() => {
setProp('scanning', false);
});
}

View File

@@ -1,124 +1,116 @@
/**
* Get the current queue and update the view
*
* @function load
*/
const load = () => {
try {
setProp('loading', true);
} catch {}
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
const component = () => {
return {
jobs: [],
paused: undefined,
loading: false,
toggling: false,
ws: undefined,
wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
console.log(`Connecting to ${url}`);
this.ws = new WebSocket(url);
this.ws.onmessage = event => {
const data = JSON.parse(event.data);
this.jobs = data.jobs;
this.paused = data.paused;
};
this.ws.onclose = () => {
if (this.ws.failed)
return this.wsConnect(false);
alert('danger', 'Socket connection closed');
};
this.ws.onerror = () => {
if (secure)
return this.ws.failed = true;
alert('danger', 'Socket connection failed');
};
},
init() {
this.wsConnect();
this.load();
},
load() {
this.loading = true;
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
this.jobs = data.jobs;
this.paused = data.paused;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-')[1];
url = `${url}?${$.param({
id: id
})}`;
}
setProp('jobs', data.jobs);
setProp('paused', data.paused);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
setProp('loading', false);
});
};
/**
* Perform an action on either a specific job or the entire queue
*
* @function jobAction
* @param {string} action - The action to perform. Should be either 'delete' or 'retry'
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
*/
const jobAction = (action, id) => {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (id !== undefined)
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
}
this.load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
toggle() {
this.toggling = true;
const action = this.paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.load();
this.toggling = false;
});
},
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
/**
* Pause/resume the download
*
* @function toggle
*/
const toggle = () => {
setProp('toggling', true);
const action = getProp('paused') ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
load();
setProp('toggling', false);
});
};
/**
* Get the uk-label class name for a given job status
*
* @function statusClass
* @param {string} status - The job status
* @return {string} The class name string
*/
const statusClass = status => {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
};
$(() => {
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
ws.onmessage = event => {
const data = JSON.parse(event.data);
setProp('jobs', data.jobs);
setProp('paused', data.paused);
return cls;
}
};
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
});
};

View File

@@ -1,293 +1,281 @@
let lastSavedPage = page;
let items = [];
let longPages = false;
const readerComponent = () => {
return {
loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null,
longPages: false,
lastSavedPage: page,
$(() => {
getPages();
/**
* Initialize the component by fetching the page dimensions
*/
init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
$('#page-select').change(() => {
const p = parseInt($('#page-select').val());
toPage(p);
});
this.items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
};
});
$('#mode-select').change(() => {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / this.items.length;
updateMode(mode, curIdx);
});
});
console.log(avgRatio);
this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
$(window).resize(() => {
const mode = getProp('mode');
if (mode === 'continuous') return;
// Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
})
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
const wideScreen = $(window).width() > $(window).height();
const propMode = wideScreen ? 'height' : 'width';
setProp('mode', propMode);
});
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
/**
* Update the reader mode
*
* @function updateMode
* @param {string} mode - The mode. Can be one of the followings:
* {'continuous', 'paged', 'height', 'width'}
* @param {number} targetPage - The one-based index of the target page
*/
const updateMode = (mode, targetPage) => {
localStorage.setItem('mode', mode);
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
if (newIdx <= 0 || newIdx > this.items.length) return;
setProp('mode', propMode);
this.toPage(newIdx);
if (mode === 'continuous') {
waitForPage(items.length, () => {
setupScroller();
});
}
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
});
};
setTimeout(() => {
this.flipAnimation = null;
}, 500);
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
});
const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
const storedMode = localStorage.getItem('mode') || 'continuous';
setProp('mode', storedMode);
updateMode(storedMode, page);
$('#mode-select').val(storedMode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
};
/**
* Check if a page exists every 100ms. If so, invoke the callback function.
*
* @function waitForPage
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {string} idx - One-based index of the current page
*/
const showControl = (idx) => {
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
}
/**
* Redirect to a URL
*
* @function redirect
* @param {string} url - The target URL
*/
const redirect = (url) => {
window.location.replace(url);
}
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const replaceHistory = (idx) => {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
});
});
};
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @function saveProgress
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
const saveProgress = (idx, cb) => {
idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 ||
longPages ||
idx === 1 || idx === items.length
) {
lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
});
}
};
},
/**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
const pageCount = this.items.length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
/**
* Mark progress to 100% and redirect to the next entry
* Used as the onclick handler for the "Next Entry" button
*
* @function nextEntry
* @param {string} nextUrl - URL of the next entry
*/
const nextEntry = (nextUrl) => {
saveProgress(items.length, () => {
redirect(nextUrl);
});
};
/**
* Show the next or the previous page
*
* @function flipPage
* @param {bool} isNext - Whether we are going to the next page
*/
const flipPage = (isNext) => {
const curItem = getProp('curItem');
const idx = parseInt(curItem.id);
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
toPage(newIdx);
if (isNext)
setProp('flipAnimation', 'right');
else
setProp('flipAnimation', 'left');
setTimeout(() => {
setProp('flipAnimation', null);
}, 500);
replaceHistory(newIdx);
saveProgress(newIdx);
};
/**
* Handle the global keydown events
*
* @function keyHandler
* @param {event} event - The $event object
*/
const keyHandler = (event) => {
const mode = getProp('mode');
if (mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
flipPage(true);
};
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and optionally sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
* @param {boolean} [markCompleted] - Whether we should mark the
* reading progress to 100%
*/
exitReader(exitUrl, markCompleted = false) {
if (!markCompleted) {
return this.redirect(exitUrl);
}
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
}
};
}

View File

@@ -252,3 +252,68 @@ const bulkProgress = (action, el) => {
deselectAll();
});
};
const tagsComponent = () => {
return {
loading: true,
isAdmin: false,
tags: [],
newTag: '',
inputShown: false,
tid: $('.upload-field').attr('data-title-id'),
load(admin) {
this.isAdmin = admin;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => {
this.tags = data.tags;
this.loading = false;
});
},
add() {
const tag = this.newTag.trim();
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT', () => {
this.tags.push(tag);
this.newTag = '';
});
},
keydown(event) {
if (event.key === 'Enter')
this.add()
},
rm(event) {
const tag = event.currentTarget.id.split('-')[0];
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE', () => {
const idx = this.tags.indexOf(tag);
if (idx < 0) return;
this.tags.splice(idx, 1);
});
},
toggleInput(nextTick) {
this.inputShown = !this.inputShown;
if (this.inputShown) {
nextTick(() => {
$('#tag-input').get(0).focus();
});
}
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success)
cb(data);
else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};

View File

@@ -68,3 +68,7 @@ shards:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.17.1
version: 0.18.3
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -39,3 +39,5 @@ dependencies:
github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
tallboy:
github: epoch/tallboy

View File

@@ -40,11 +40,6 @@ describe Rule do
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"
@@ -69,8 +64,13 @@ describe Rule do
rule.render({} of String => String).should eq "testing"
end
it "escapes slash" do
rule = Rule.new "{id}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
it "escapes illegal characters" do
rule = Rule.new "{a}"
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
end
it "strips trailing spaces and dots" do
rule = Rule.new "hello. world. .."
rule.render({} of String => String).should eq "hello. world"
end
end

View File

@@ -20,6 +20,9 @@ class Config
property plugin_path : String = File.expand_path "~/mango/plugins",
home: true
property download_timeout_seconds : Int32 = 30
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)]
@@ -85,5 +88,9 @@ class Config
unless base_url.ends_with? "/"
@base_url += "/"
end
if disable_login && default_username.empty?
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
end
end

View File

@@ -11,9 +11,6 @@ class AuthHandler < Kemal::Handler
"You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def require_basic_auth(env)
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
@@ -23,12 +20,12 @@ class AuthHandler < Kemal::Handler
def validate_token(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_token token
!token.nil? && Storage.default.verify_token token
end
def validate_token_admin(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_admin token
!token.nil? && Storage.default.verify_admin token
end
def validate_auth_header(env)
@@ -49,7 +46,7 @@ class AuthHandler < Kemal::Handler
def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":")
@storage.verify_user username, password
Storage.default.verify_user username, password
end
def handle_opds_auth(env)
@@ -68,15 +65,24 @@ class AuthHandler < Kemal::Handler
return call_next(env)
end
unless validate_token env
unless validate_token(env) || Config.current.disable_login
env.session.string "callback", env.request.path
return redirect env, "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless validate_token_admin env
env.response.status_code = 403
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
env.response.status_code = 403 if should_reject
end
call_next env

View File

@@ -1,11 +1,12 @@
require "image_size"
class Entry
property zip_path : String, book : Title, title : String,
getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book, storage)
def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title

View File

@@ -1,5 +1,5 @@
class Library
property dir : String, title_ids : Array(String),
getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title)
use_default
@@ -68,29 +68,8 @@ class Library
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
# Helper function from src/util/util.cr
sort_titles titles, opt.not_nil!, username
end
def deep_titles
@@ -127,7 +106,7 @@ class Library
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", storage, self }
.map { |path| Title.new path, "" }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }

View File

@@ -1,13 +1,14 @@
require "../archive"
class Title
property dir : String, parent_id : String, title_ids : Array(String),
getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time,
entry_display_name_cache : Hash(String, String)?
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@library : Library)
@entry_display_name_cache : Hash(String, String)?
def initialize(@dir : String, @parent_id)
storage = Storage.default
id = storage.get_id @dir, true
if id.nil?
id = random_str
@@ -28,26 +29,26 @@ class Title
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, library
title = Title.new path, @id
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
Library.default.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
entry = Entry.new path, self, storage
entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title,
@library.title_hash[b].title
compare_numerically Library.default.title_hash[a].title,
Library.default.title_hash[b].title
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b|
@@ -83,7 +84,7 @@ class Title
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
@title_ids.map { |tid| Library.default.get_title! tid }
end
# Get all entries, including entries in nested titles
@@ -101,15 +102,37 @@ class Title
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
title = Library.default.get_title! tid
ary << title
tid = title.parent_id
end
ary.reverse
end
def size
@entries.size + @title_ids.size
# Returns a string the describes the content of the title
# e.g., - 3 titles and 1 entry
# - 4 entries
# - 1 title
def content_label
ary = [] of String
tsize = titles.size
esize = entries.size
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
ary.join " and "
end
def tags
Storage.default.get_title_tags @id
end
def add_tag(tag)
Storage.default.add_tag @id, tag
end
def delete_tag(tag)
Storage.default.delete_tag @id, tag
end
def get_entry(eid)

View File

@@ -3,11 +3,12 @@ require "./queue"
require "./server"
require "./main_fiber"
require "./mangadex/*"
require "./plugin/*"
require "option_parser"
require "clim"
require "./plugin/*"
require "tallboy"
MANGO_VERSION = "0.17.1"
MANGO_VERSION = "0.18.3"
# From http://www.network-science.de/ascii/
BANNER = %{
@@ -53,6 +54,11 @@ class CLI < Clim
ARGV.clear
Config.load(opts.config).set_current
# Initialize main components
Storage.default
Queue.default
Library.default
MangaDex::Downloader.default
Plugin::Downloader.default
@@ -105,18 +111,13 @@ class CLI < Clim
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} "
table = Tallboy.table do
header ["username", "admin access"]
users.each do |name, admin|
row [name, admin]
end
end
puts "-" * header.size
puts table
when nil
puts opts.help_string
else

View File

@@ -257,6 +257,48 @@ class Plugin
end
sbx.put_prop_string -2, "get"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
body = env.require_string 1
headers = HTTP::Headers.new
if env.get_top == 3
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.post url, headers, body
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "post"
sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0

View File

@@ -139,9 +139,13 @@ module Rename
post_process str
end
# Post-processes the generated file/folder name
# - Handles the rare case where the string is `..`
# - Removes trailing spaces and periods
# - Replace illegal characters with `_`
private def post_process(str)
return "_" if str == ".."
str.gsub "/", "_"
str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
end
end
end

View File

@@ -1,13 +1,11 @@
require "./router"
class AdminRouter < Router
struct AdminRouter
def initialize
get "/admin" do |env|
layout "admin"
end
get "/admin/user" do |env|
users = @context.storage.list_users
users = Storage.default.list_users
username = get_username env
layout "user"
end
@@ -32,11 +30,11 @@ class AdminRouter < Router
# would not contain `admin`
admin = !env.params.body["admin"]?.nil?
@context.storage.new_user username, password, admin
Storage.default.new_user username, password, admin
redirect env, "/admin/user"
rescue e
@context.error e
Logger.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"error" => e.message})
@@ -51,12 +49,12 @@ class AdminRouter < Router
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
@context.storage.update_user \
Storage.default.update_user \
original_username, username, password, admin
redirect env, "/admin/user"
rescue e
@context.error e
Logger.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \

View File

@@ -1,9 +1,8 @@
require "./router"
require "../mangadex/*"
require "../upload"
require "koa"
class APIRouter < Router
struct APIRouter
@@api_json : String?
API_VERSION = "0.1.0"
@@ -153,6 +152,7 @@ class APIRouter < Router
Koa.object "dimensionResult", {
"success" => "boolean",
"dimensions" => "$dimensionAry?",
"margin" => "number",
"error" => "string?",
}
@@ -160,6 +160,12 @@ class APIRouter < Router
"ids" => "$strAry",
}
Koa.object "tagsResult", {
"success" => "boolean",
"tags" => "$strAry?",
"error" => "string?",
}
Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
@@ -172,7 +178,7 @@ class APIRouter < Router
eid = env.params.url["eid"]
page = env.params.url["page"].to_i
title = @context.library.get_title tid
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -182,7 +188,7 @@ class APIRouter < Router
send_img env, img
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
e.message
end
@@ -198,7 +204,7 @@ class APIRouter < Router
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -209,7 +215,7 @@ class APIRouter < Router
send_img env, img
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
e.message
end
@@ -222,12 +228,12 @@ class APIRouter < Router
get "/api/book/:tid" do |env|
begin
tid = env.params.url["tid"]
title = @context.library.get_title tid
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json
rescue e
@context.error e
Logger.error e
env.response.status_code = 404
e.message
end
@@ -236,7 +242,7 @@ class APIRouter < Router
Koa.describe "Returns the entire library with all titles and entries"
Koa.response 200, ref: "$library"
get "/api/library" do |env|
send_json env, @context.library.to_json
send_json env, Library.default.to_json
end
Koa.describe "Triggers a library scan"
@@ -244,11 +250,11 @@ class APIRouter < Router
Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
Library.default.scan
ms = (Time.utc - start).total_milliseconds
send_json env, {
"milliseconds" => ms,
"titles" => @context.library.titles.size,
"titles" => Library.default.titles.size,
}.to_json
end
@@ -275,9 +281,9 @@ class APIRouter < Router
delete "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
Storage.default.delete_user username
rescue e
@context.error e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
@@ -302,7 +308,7 @@ class APIRouter < Router
put "/api/progress/:tid/:page" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["tid"]).not_nil!
title = (Library.default.get_title env.params.url["tid"]).not_nil!
page = env.params.url["page"].to_i
entry_id = env.params.query["eid"]?
@@ -316,7 +322,7 @@ class APIRouter < Router
title.read_all username
end
rescue e
@context.error e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
@@ -334,7 +340,7 @@ class APIRouter < Router
put "/api/bulk_progress/:action/:tid" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["tid"]).not_nil!
title = (Library.default.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s
@@ -343,7 +349,7 @@ class APIRouter < Router
end
title.bulk_progress action, ids, username
rescue e
@context.error e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
@@ -363,7 +369,7 @@ class APIRouter < Router
Koa.response 200, ref: "$result"
put "/api/admin/display_name/:tid/:name" do |env|
begin
title = (@context.library.get_title env.params.url["tid"])
title = (Library.default.get_title env.params.url["tid"])
.not_nil!
name = env.params.url["name"]
entry = env.params.query["eid"]?
@@ -374,7 +380,7 @@ class APIRouter < Router
title.set_display_name eobj.not_nil!.title, name
end
rescue e
@context.error e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
@@ -397,7 +403,7 @@ class APIRouter < Router
manga = api.get_manga id
send_json env, manga.to_info_json
rescue e
@context.error e
Logger.error e
send_json env, {"error" => e.message}.to_json
end
end
@@ -421,13 +427,13 @@ class APIRouter < Router
Time.unix chapter["time"].as_s.to_i
)
}
inserted_count = @context.queue.push jobs
inserted_count = Queue.default.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
@context.error e
Logger.error e
send_json env, {"error" => e.message}.to_json
end
end
@@ -437,8 +443,8 @@ class APIRouter < Router
interval = (interval_raw.to_i? if interval_raw) || 5
loop do
socket.send({
"jobs" => @context.queue.get_all,
"paused" => @context.queue.paused?,
"jobs" => Queue.default.get_all,
"paused" => Queue.default.paused?,
}.to_json)
sleep interval.seconds
end
@@ -451,10 +457,10 @@ class APIRouter < Router
Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env|
begin
jobs = @context.queue.get_all
jobs = Queue.default.get_all
send_json env, {
"jobs" => jobs,
"paused" => @context.queue.paused?,
"paused" => Queue.default.paused?,
"success" => true,
}.to_json
rescue e
@@ -485,20 +491,20 @@ class APIRouter < Router
case action
when "delete"
if id.nil?
@context.queue.delete_status Queue::JobStatus::Completed
Queue.default.delete_status Queue::JobStatus::Completed
else
@context.queue.delete id
Queue.default.delete id
end
when "retry"
if id.nil?
@context.queue.reset
Queue.default.reset
else
@context.queue.reset id
Queue.default.reset id
end
when "pause"
@context.queue.pause
Queue.default.pause
when "resume"
@context.queue.resume
Queue.default.resume
else
raise "Unknown queue action #{action}"
end
@@ -544,7 +550,7 @@ class APIRouter < Router
when "cover"
title_id = env.params.query["tid"]
entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil!
title = Library.default.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename
@@ -628,7 +634,7 @@ class APIRouter < Router
Time.utc
)
}
inserted_count = @context.queue.push jobs
inserted_count = Queue.default.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
@@ -650,7 +656,7 @@ class APIRouter < Router
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -659,6 +665,7 @@ class APIRouter < Router
send_json env, {
"success" => true,
"dimensions" => sizes,
"margin" => Config.current.page_margin,
}.to_json
rescue e
send_json env, {
@@ -675,16 +682,83 @@ class APIRouter < Router
Koa.response 404, "Entry not found"
get "/api/download/:tid/:eid" do |env|
begin
title = (@context.library.get_title env.params.url["tid"]).not_nil!
title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
Logger.error e
env.response.status_code = 404
end
end
Koa.describe "Gets the tags of a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$tagsResult"
get "/api/tags/:tid" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tags = title.tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
put "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.add_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a tag from a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.delete_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate
@@api_json = doc.to_json if doc

View File

@@ -1,6 +1,4 @@
require "./router"
class MainRouter < Router
struct MainRouter
def initialize
get "/login" do |env|
base_url = Config.current.base_url
@@ -11,7 +9,7 @@ class MainRouter < Router
begin
env.session.delete_string "token"
rescue e
@context.error "Error when attempting to log out: #{e}"
Logger.error "Error when attempting to log out: #{e}"
ensure
redirect env, "/login"
end
@@ -21,7 +19,7 @@ class MainRouter < Router
begin
username = env.params.body["username"]
password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil!
token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token
@@ -41,22 +39,22 @@ class MainRouter < Router
begin
username = get_username env
sort_opt = SortOptions.from_info_json @context.library.dir, username
sort_opt = SortOptions.from_info_json Library.default.dir, username
get_sort_opt
titles = @context.library.sorted_titles username, sort_opt
titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
end
end
get "/book/:title" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
title = (Library.default.get_title env.params.url["title"]).not_nil!
username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username
@@ -68,7 +66,7 @@ class MainRouter < Router
title_percentage = title.titles.map &.load_percentage username
layout "title"
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
end
end
@@ -92,7 +90,7 @@ class MainRouter < Router
layout "plugin-download"
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
end
end
@@ -100,20 +98,61 @@ class MainRouter < Router
get "/" do |env|
begin
username = get_username env
continue_reading = @context
.library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username
titles = @context.library.titles
continue_reading = Library.default
.get_continue_reading_entries username
recently_added = Library.default.get_recently_added_entries username
start_reading = Library.default.get_start_reading_titles username
titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0
layout "home"
rescue e
@context.error e
Logger.error e
env.response.status_code = 500
end
end
get "/tags/:tag" do |env|
begin
username = get_username env
tag = env.params.url["tag"]
sort_opt = SortOptions.new
get_sort_opt
title_ids = Storage.default.get_tag_titles tag
raise "Tag #{tag} not found" if title_ids.empty?
titles = title_ids.map { |id| Library.default.get_title id }
.select Title
titles = sort_titles titles, sort_opt, username
percentage = titles.map &.load_percentage username
layout "tag"
rescue e
Logger.error e
env.response.status_code = 404
end
end
get "/tags" do |env|
tags = Storage.default.list_tags.map do |tag|
{
tag: tag,
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
count: Storage.default.get_tag_titles(tag).size,
}
end
# Sort by :count reversly, and then sort by :tag
tags.sort! do |a, b|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
end
layout "tags"
end
get "/api" do |env|
render "src/views/api.html.ecr"
end

View File

@@ -1,18 +1,16 @@
require "./router"
class OPDSRouter < Router
struct OPDSRouter
def initialize
get "/opds" do |env|
titles = @context.library.titles
titles = Library.default.titles
render_xml "src/views/opds/index.xml.ecr"
end
get "/opds/book/:title_id" do |env|
begin
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
title = Library.default.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.xml.ecr"
rescue e
@context.error e
Logger.error e
env.response.status_code = 404
end
end

View File

@@ -1,25 +1,23 @@
require "./router"
class ReaderRouter < Router
struct ReaderRouter
def initialize
get "/reader/:title/:entry" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg
# load progress
page = [1, entry.load_progress username].max
page_idx = [1, entry.load_progress username].max
# start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username
page_idx = 1 if entry.finished? username
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
rescue e
@context.error e
Logger.error e
env.response.status_code = 404
end
end
@@ -30,10 +28,12 @@ class ReaderRouter < Router
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0
page_idx = env.params.url["page"].to_i
if page_idx > entry.pages || page_idx <= 0
raise "Page #{page_idx} not found."
end
exit_url = "#{base_url}book/#{title.id}"
@@ -45,7 +45,7 @@ class ReaderRouter < Router
render "src/views/reader.html.ecr"
rescue e
@context.error e
Logger.error e
env.response.status_code = 404
end
end

View File

@@ -1,3 +0,0 @@
class Router
@context : Context = Context.default
end

View File

@@ -5,29 +5,7 @@ require "./handlers/*"
require "./util/*"
require "./routes/*"
class Context
property library : Library
property storage : Storage
property queue : Queue
use_default
def initialize
@storage = Storage.default
@library = Library.default
@queue = Queue.default
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
Logger.{{lvl.id}} msg
end
{% end %}
end
class Server
@context : Context = Context.default
def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
@@ -53,11 +31,11 @@ class Server
Kemal.config.logging = false
add_handler LogHandler.new
add_handler AuthHandler.new @context.storage
add_handler AuthHandler.new
add_handler UploadHandler.new Config.current.upload_path
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files."
Logger.debug "We are in release mode. Using embedded static files."
serve_static false
add_handler StaticHandler.new
{% end %}
@@ -71,7 +49,7 @@ class Server
end
def start
@context.debug "Starting Kemal server"
Logger.debug "Starting Kemal server"
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}

View File

@@ -35,16 +35,24 @@ class Storage
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
begin
# v0.18.0
db.exec "create table tags (id text, tag text, unique (id, tag))"
db.exec "create index tags_id_idx on tags (id)"
db.exec "create index tags_tag_idx on tags (tag)"
# v0.15.0
db.exec "create table thumbnails " \
"(id text, data blob, filename text, " \
"mime text, size integer)"
db.exec "create unique index tn_index on thumbnails (id)"
# v0.1.1
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
# v0.1.0
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
@@ -64,6 +72,14 @@ class Storage
init_admin if init_user
end
# Verifies that the default username in config is valid
if Config.current.disable_login
username = Config.current.default_username
unless username_exists username
raise "Default username #{username} does not exist"
end
end
end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
@@ -90,6 +106,28 @@ class Storage
end
end
def username_exists(username)
exists = false
MainFiber.run do
get_db do |db|
exists = db.query_one("select count(*) from users where " \
"username = (?)", username, as: Int32) > 0
end
end
exists
end
def username_is_admin(username)
is_admin = false
MainFiber.run do
get_db do |db|
is_admin = db.query_one("select admin from users where " \
"username = (?)", username, as: Int32) > 0
end
end
is_admin
end
def verify_user(username, password)
out_token = nil
MainFiber.run do
@@ -266,6 +304,70 @@ class Storage
img
end
def get_title_tags(id : String) : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def get_tag_titles(tag : String) : Array(String)
tids = [] of String
MainFiber.run do
get_db do |db|
db.query "select id from tags where tag = (?)", tag do |rs|
rs.each do
tids << rs.read String
end
end
end
end
tids
end
def list_tags : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select distinct tag from tags" do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def add_tag(id : String, tag : String)
err = nil
MainFiber.run do
begin
get_db do |db|
db.exec "insert into tags values (?, ?)", id, tag
end
rescue e
err = e
end
end
raise err.not_nil! if err
end
def delete_tag(id : String, tag : String)
MainFiber.run do
get_db do |db|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
end
end
end
def optimize
MainFiber.run do
Logger.info "Starting DB optimization"
@@ -292,6 +394,15 @@ class Storage
db.exec "delete from thumbnails where id not in (select id from ids)"
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
end
# Delete dangling tags
trash_tags_count = db.query_one "select count(*) from tags " \
"where id not in " \
"(select id from ids)", as: Int32
if trash_tags_count > 0
db.exec "delete from tags where id not in (select id from ids)"
Logger.info "#{trash_tags_count} dangling tags deleted"
end
end
Logger.info "DB optimization finished"
end

View File

@@ -67,3 +67,28 @@ def env_is_true?(key : String) : Bool
return false unless val
val.downcase.in? "1", "true"
end
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary = titles
case opt.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end

View File

@@ -4,14 +4,22 @@ macro layout(name)
base_url = Config.current.base_url
begin
is_admin = false
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
if Config.current.disable_login
is_admin = Storage.default.
username_is_admin Config.current.default_username
end
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
is_admin = Storage.default.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
@context.error message
Logger.error message
page = "Error"
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
@@ -21,10 +29,16 @@ macro send_img(env, img)
end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
begin
token = env.session.string "token"
(Storage.default.verify_token token).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
else
raise e
end
end
end
def send_json(env, json)
@@ -46,12 +60,7 @@ def hash_to_query(hash)
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
false
ary.any? { |prefix| env.request.path.starts_with? prefix }
end
def requesting_static_file(env)

View File

@@ -1,21 +1,25 @@
<ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}">
<li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li>
<li :class="{'nopointer' : scanning}" @click="scan()">
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<div class="uk-align-right">
<div uk-spinner x-show="scanning"></div>
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</div>
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li>
<a class="uk-link-reset" @click="scan()">
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<div class="uk-align-right">
<div uk-spinner x-show="scanning"></div>
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</div>
</a>
</li>
<li :class="{'nopointer' : generating}" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
<li>
<a class="uk-link-reset" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
</a>
</li>
<li class="nopointer">
<li>
<span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2">
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
<option>Dark</option>
<option>Light</option>
<option>System</option>

View File

@@ -76,7 +76,7 @@
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.size %> entries</p>
<p class="uk-text-meta"><%= item.content_label %></p>
<% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %>

View File

@@ -1,7 +1,7 @@
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
@@ -12,7 +12,7 @@
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script>
</head>

View File

@@ -0,0 +1,12 @@
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)">
<p class="uk-text-meta" @selectstart.prevent>
<span style="position:relative; bottom:3px; margin-right:5px;">Tags: </span>
<template x-for="tag in tags" :key="tag">
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" x-show="isAdmin" @click="rm($event)" :id="`${tag}-rm`"><span uk-icon="close" style="margin-right: 5px; position: relative; bottom: 1.5px;"></span></a><a class="uk-link-reset" x-text="tag" :href="`<%= base_url %>tags/${encodeURIComponent(tag)}`"></a>
</span>
</template>
<a class="uk-link-reset" style="position:relative; bottom:3px;" :uk-icon="inputShown ? 'close' : 'plus'" @click="toggleInput($nextTick)" x-show="isAdmin"></a>
</p>
<input id="tag-input" class="uk-input" type="text" placeholder="Type in a new tag and hit enter" x-model="newTag" @keydown="keydown($event)" x-show="inputShown">
</div>

View File

@@ -1,4 +1,4 @@
<div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
<div x-data="component()" x-init="init()">
<div class="uk-margin">
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
@@ -51,9 +51,9 @@
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a>
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr>

View File

@@ -11,6 +11,7 @@
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent">
@@ -40,6 +41,7 @@
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html>
<% page = "Login" %>
<%= render_component "head" %>
<body>

View File

@@ -21,9 +21,7 @@
<% content_for "script" do %>
<script>
UIkit.modal('#modal').show().then(function() {
styleModal();
});
UIkit.modal('#modal').show();
UIkit.util.on('#modal', 'hide', function() {
location.href = "<%= base_url %>book/<%= entry.book.id %>";
});

View File

@@ -1,21 +1,11 @@
<!DOCTYPE html>
<html class="reader-bg">
<% page = "Reader" %>
<%= render_component "head" %>
<body style="position:relative;">
<div class="uk-section uk-section-default uk-section-small reader-bg"
id="root"
:style="mode === 'continuous' ? '' : 'padding:0'"
x-data="{
loading: true,
mode: 'continuous', // can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null
}">
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div>
@@ -29,37 +19,38 @@
</div>
<div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items">
<img
uk-img
class="uk-align-center"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
:onclick="`showControl('${item.id}')`"
/>
uk-img
class="uk-align-center"
:style="item.style"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
@click="showControl($event)"
/>
</template>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
<%- end -%>
</div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
`" />
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
`" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
@@ -82,7 +73,7 @@
<div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label>
<div class="uk-form-controls">
<select id="page-select" class="uk-select">
<select id="page-select" class="uk-select" @change="pageChanged()">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
@@ -92,7 +83,7 @@
<div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls">
<select id="mode-select" class="uk-select">
<select id="mode-select" class="uk-select" @change="modeChanged($nextTick)">
<option value="continuous">Continuous</option>
<option value="paged">Paged</option>
</select>
@@ -100,14 +91,14 @@
</div>
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
</div>
</div>
</div>
<script>
const base_url = "<%= base_url %>";
const page = <%= page %>;
const page = <%= page_idx %>;
const tid = "<%= title.id %>";
const eid = "<%= entry.id %>";
</script>
@@ -120,7 +111,7 @@
<style>
img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; }
img { width: 100%; }
</style>
</html>

30
src/views/tag.html.ecr Normal file
View File

@@ -0,0 +1,30 @@
<h2 class=uk-title>Tag: <%= tag %></h2>
<p class="uk-text-meta"><%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> tagged</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

8
src/views/tags.html.ecr Normal file
View File

@@ -0,0 +1,8 @@
<h2 class=uk-title>Tags</h2>
<p class="uk-text-meta"><%= tags.size %> <%= tags.size > 1 ? "tags" : "tag" %> found</p>
<% tags.each do |tag| %>
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" href="<%= base_url %>tags/<%= tag[:encoded_tag] %>"><%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>)</a>
</span>
<% end %>

View File

@@ -32,7 +32,10 @@
<%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries found</p>
<p class="uk-text-meta"><%= title.content_label %> found</p>
<%= render_component "tags" %>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">