Compare commits

...

54 Commits

Author SHA1 Message Date
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
Alex Ling f7b8e2d852 Bump version to v0.17.1 2020-12-27 09:46:14 +00:00
Alex Ling 946017c8bd Fix function redeclaration 2020-12-27 09:42:06 +00:00
Alex Ling ec5256dabd Improve batch mark UX (#97) 2020-12-27 09:42:06 +00:00
Alex Ling 4e707076a1 By default use the system theme setting (#111) 2020-12-27 09:42:06 +00:00
Alex Ling 66a3cc268b Merge branch 'master' into dev 2020-12-26 09:34:23 +00:00
Alex Ling 96949905b9 Cache entry display names
This improves the title page load time (#116)
2020-12-26 09:32:03 +00:00
40 changed files with 1106 additions and 756 deletions
+6 -2
View File
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.17.0 Mango - Manga Server and Web Reader. Version 0.18.1
Usage: Usage:
@@ -87,18 +87,22 @@ log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins plugin_path: ~/mango/plugins
download_timeout_seconds: 30 download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
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: /home/alex_ling/mango/queue.db download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}' chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}' 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 - `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 - `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 ### Library Structure
-8
View File
@@ -31,14 +31,6 @@
cursor: pointer; cursor: pointer;
} }
.uk-list li:not(.nopointer) {
cursor: pointer;
}
#scan-status {
cursor: auto;
}
.reader-bg { .reader-bg {
background-color: black; background-color: black;
} }
+47 -82
View File
@@ -1,90 +1,55 @@
$(() => { const component = () => {
return {
progress: 1.0,
generating: false,
scanning: false,
scanTitles: 0,
scanMs: -1,
themeSetting: '',
init() {
this.getProgress();
setInterval(() => {
this.getProgress();
}, 5000);
const setting = loadThemeSetting(); const setting = loadThemeSetting();
$('#theme-select').val(capitalize(setting)); this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
$('#theme-select').change((e) => { },
const newSetting = $(e.currentTarget).val().toLowerCase(); themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
}); },
scan() {
getProgress(); if (this.scanning) return;
setInterval(getProgress, 5000); this.scanning = true;
}); this.scanMs = -1;
this.scanTitles = 0;
/**
* 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);
};
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
*/
const setProp = (key, prop) => {
$('#root').get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @return {*} The data property
*/
const getProp = (key) => {
return $('#root').get(0).__x.$data[key];
};
/**
* 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`) $.post(`${base_url}api/admin/scan`)
.then(data => { .then(data => {
setProp('scanMs', data.milliseconds); this.scanMs = data.milliseconds;
setProp('scanTitles', data.titles); this.scanTitles = data.titles;
}) })
.always(() => { .always(() => {
setProp('scanning', false); 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;
});
},
};
};
+2 -2
View File
@@ -63,7 +63,7 @@ const validThemeSetting = (theme) => {
*/ */
const loadThemeSetting = () => { const loadThemeSetting = () => {
let str = localStorage.getItem('theme'); let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light'; if (!str || !validThemeSetting(str)) str = 'system';
return str; return str;
}; };
@@ -88,7 +88,7 @@ const loadTheme = () => {
* @param {string} setting - A theme setting * @param {string} setting - A theme setting
*/ */
const saveThemeSetting = setting => { const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light'; if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting); localStorage.setItem('theme', setting);
}; };
+45 -62
View File
@@ -1,12 +1,28 @@
/** const component = () => {
* Get the current queue and update the view return {
* jobs: [],
* @function load paused: undefined,
*/ loading: false,
const load = () => { toggling: false,
try {
setProp('loading', true); init() {
} catch {} const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
ws.onmessage = event => {
const data = JSON.parse(event.data);
this.jobs = data.jobs;
this.paused = data.paused;
};
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
this.load();
},
load() {
this.loading = true;
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: base_url + 'api/admin/mangadex/queue', url: base_url + 'api/admin/mangadex/queue',
@@ -17,30 +33,24 @@ const load = () => {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`); alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return; return;
} }
setProp('jobs', data.jobs); this.jobs = data.jobs;
setProp('paused', data.paused); this.paused = data.paused;
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.always(() => { .always(() => {
setProp('loading', false); this.loading = false;
}); });
}; },
jobAction(action, event) {
/**
* 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}`; let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (id !== undefined) if (event) {
url += '?' + $.param({ const id = event.currentTarget.closest('tr').id.split('-')[1];
url = `${url}?${$.param({
id: id id: id
}); })}`;
}
console.log(url); console.log(url);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -52,21 +62,15 @@ const jobAction = (action, id) => {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`); alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return; return;
} }
load(); this.load();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; },
toggle() {
/** this.toggling = true;
* Pause/resume the download const action = this.paused ? 'resume' : 'pause';
*
* @function toggle
*/
const toggle = () => {
setProp('toggling', true);
const action = getProp('paused') ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`; const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -77,19 +81,11 @@ const toggle = () => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.always(() => { .always(() => {
load(); this.load();
setProp('toggling', false); this.toggling = false;
}); });
}; },
statusClass(status) {
/**
* 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 '; let cls = 'label ';
switch (status) { switch (status) {
case 'Pending': case 'Pending':
@@ -106,19 +102,6 @@ const statusClass = status => {
break; break;
} }
return cls; 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);
}; };
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
});
+199 -211
View File
@@ -1,222 +1,171 @@
let lastSavedPage = page; const readerComponent = () => {
let items = []; return {
let longPages = false; loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
$(() => { msg: 'Loading the web reader. Please wait...',
getPages(); alertClass: 'uk-alert-primary',
items: [],
$('#page-select').change(() => { curItem: {},
const p = parseInt($('#page-select').val()); flipAnimation: null,
toPage(p); longPages: false,
}); lastSavedPage: page,
$('#mode-select').change(() => {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
updateMode(mode, curIdx);
});
});
$(window).resize(() => {
const mode = getProp('mode');
if (mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
const propMode = wideScreen ? 'height' : 'width';
setProp('mode', propMode);
});
/** /**
* Update the reader mode * Initialize the component by fetching the page dimensions
*
* @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) => { init(nextTick) {
localStorage.setItem('mode', mode);
// 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';
}
setProp('mode', propMode);
if (mode === 'continuous') {
waitForPage(items.length, () => {
setupScroller();
});
}
waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
});
};
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) $.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => { .then(data => {
if (!data.success && data.error) if (!data.success && data.error)
throw new Error(resp.error); throw new Error(resp.error);
const dimensions = data.dimensions; const dimensions = data.dimensions;
items = dimensions.map((d, i) => { this.items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width, width: d.width,
height: d.height height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
}; };
}); });
const avgRatio = items.reduce((acc, cur) => { const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width return acc + cur.height / cur.width
}, 0) / items.length; }, 0) / this.items.length;
console.log(avgRatio); console.log(avgRatio);
longPages = avgRatio > 2; this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
setProp('items', items); // Here we save a copy of this.mode, and use the copy as
setProp('loading', false); // the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
const storedMode = localStorage.getItem('mode') || 'continuous'; // which are not available in mode-select
const mode = this.mode;
setProp('mode', storedMode); this.updateMode(this.mode, page, nextTick);
updateMode(storedMode, page); $('#mode-select').val(mode);
$('#mode-select').val(storedMode);
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e); console.error(e);
setProp('alertClass', 'uk-alert-danger'); this.alertClass = 'uk-alert-danger';
setProp('msg', errMsg); this.msg = errMsg;
}) })
}; },
/** /**
* Jump to a specific page * 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());
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
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;
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 (newIdx <= 0 || newIdx > this.items.length) return;
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
setTimeout(() => {
this.flipAnimation = null;
}, 500);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
* *
* @function toPage
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
*/ */
const toPage = (idx) => { toPage(idx) {
const mode = getProp('mode'); if (this.mode === 'continuous') {
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true); $(`#${idx}`).get(0).scrollIntoView(true);
} else { } else {
if (idx >= 1 && idx <= items.length) { if (idx >= 1 && idx <= this.items.length) {
setProp('curItem', items[idx - 1]); this.curItem = this.items[idx - 1];
} }
} }
replaceHistory(idx); this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide(); 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 the reading progress if necessary * Replace the address bar history and save the reading progress if necessary
* *
* @function replaceHistory * @param {number} idx - One-based index of the page
* @param {number} idx - One-based index of the current page
*/ */
const replaceHistory = (idx) => { replaceHistory(idx) {
const ary = window.location.pathname.split('/'); const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx; ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/` ary.shift(); // remove leading `/`
ary.unshift(window.location.origin); ary.unshift(window.location.origin);
const url = ary.join('/'); const url = ary.join('/');
saveProgress(idx); this.saveProgress(idx);
history.replaceState(null, "", url); history.replaceState(null, "", url);
} },
/** /**
* Set up the scroll handler that calls `replaceHistory` when an image * Updates the backend reading progress if:
* 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);
}
});
});
};
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last * 1) the current page is more than five pages away from the last
* saved page, or * saved page, or
* 2) the average height/width ratio of the pages is over 2, or * 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or * 3) the current page is the first page, or
* 4) the current page is the last page * 4) the current page is the last page
* *
* @function saveProgress
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback * @param {function} cb - Callback
*/ */
const saveProgress = (idx, cb) => { saveProgress(idx, cb) {
idx = parseInt(idx); idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 || if (Math.abs(idx - this.lastSavedPage) >= 5 ||
longPages || this.longPages ||
idx === 1 || idx === items.length idx === 1 || idx === this.items.length
) { ) {
lastSavedPage = idx; this.lastSavedPage = idx;
console.log('saving progress', idx); console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`; const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
@@ -234,60 +183,99 @@ const saveProgress = (idx, cb) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
} }
}; },
/** /**
* Mark progress to 100% and redirect to the next entry * Updates the reader mode
* Used as the onclick handler for the "Next Entry" button *
* @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);
// 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');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
* *
* @function nextEntry
* @param {string} nextUrl - URL of the next entry * @param {string} nextUrl - URL of the next entry
*/ */
const nextEntry = (nextUrl) => { nextEntry(nextUrl) {
saveProgress(items.length, () => { this.saveProgress(this.items.length, () => {
redirect(nextUrl); this.redirect(nextUrl);
}); });
}; },
/** /**
* Show the next or the previous page * Exits the reader, and optionally sets the reading progress tp 100%
* *
* @function flipPage * @param {string} exitUrl - The Exit URL
* @param {bool} isNext - Whether we are going to the next page * @param {boolean} [markCompleted] - Whether we should mark the
* reading progress to 100%
*/ */
const flipPage = (isNext) => { exitReader(exitUrl, markCompleted = false) {
const curItem = getProp('curItem'); if (!markCompleted) {
const idx = parseInt(curItem.id); return this.redirect(exitUrl);
const delta = isNext ? 1 : -1; }
const newIdx = idx + delta; this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
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);
}; };
}
+65
View File
@@ -252,3 +252,68 @@ const bulkProgress = (action, el) => {
deselectAll(); 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}`);
});
}
};
};
+4
View File
@@ -68,3 +68,7 @@ shards:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3
+3 -1
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.17.0 version: 0.18.1
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -39,3 +39,5 @@ dependencies:
github: hkalexling/image_size.cr github: hkalexling/image_size.cr
koa: koa:
github: hkalexling/koa github: hkalexling/koa
tallboy:
github: epoch/tallboy
+8 -8
View File
@@ -40,11 +40,6 @@ describe Rule do
rule.render({"a" => "a", "b" => "b"}).should eq "a" rule.render({"a" => "a", "b" => "b"}).should eq "a"
end 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 it "raises on escaped characters" do
expect_raises Exception do expect_raises Exception do
Rule.new "hello/world" Rule.new "hello/world"
@@ -69,8 +64,13 @@ describe Rule do
rule.render({} of String => String).should eq "testing" rule.render({} of String => String).should eq "testing"
end end
it "escapes slash" do it "escapes illegal characters" do
rule = Rule.new "{id}" rule = Rule.new "{a}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world" 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
end end
+7
View File
@@ -20,6 +20,9 @@ class Config
property plugin_path : String = File.expand_path "~/mango/plugins", property plugin_path : String = File.expand_path "~/mango/plugins",
home: true home: true
property download_timeout_seconds : Int32 = 30 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 property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -85,5 +88,9 @@ class Config
unless base_url.ends_with? "/" unless base_url.ends_with? "/"
@base_url += "/" @base_url += "/"
end 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
end end
+15 -9
View File
@@ -11,9 +11,6 @@ class AuthHandler < Kemal::Handler
"You have to login with proper credentials" "You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\"" HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def require_basic_auth(env) def require_basic_auth(env)
env.response.status_code = 401 env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
@@ -23,12 +20,12 @@ class AuthHandler < Kemal::Handler
def validate_token(env) def validate_token(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_token token !token.nil? && Storage.default.verify_token token
end end
def validate_token_admin(env) def validate_token_admin(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_admin token !token.nil? && Storage.default.verify_admin token
end end
def validate_auth_header(env) def validate_auth_header(env)
@@ -49,7 +46,7 @@ class AuthHandler < Kemal::Handler
def verify_user(value) def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1]) username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":") .split(":")
@storage.verify_user username, password Storage.default.verify_user username, password
end end
def handle_opds_auth(env) def handle_opds_auth(env)
@@ -68,15 +65,24 @@ class AuthHandler < Kemal::Handler
return call_next(env) return call_next(env)
end end
unless validate_token env unless validate_token(env) || Config.current.disable_login
env.session.string "callback", env.request.path env.session.string "callback", env.request.path
return redirect env, "/login" return redirect env, "/login"
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless validate_token_admin env # The token (if exists) takes precedence over the default user option.
env.response.status_code = 403 # 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 end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
env.response.status_code = 403 if should_reject
end end
call_next env call_next env
+3 -2
View File
@@ -1,11 +1,12 @@
require "image_size" require "image_size"
class Entry 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, size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : 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 @encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path @title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
+4 -25
View File
@@ -1,5 +1,5 @@
class Library class Library
property dir : String, title_ids : Array(String), getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
use_default use_default
@@ -68,29 +68,8 @@ class Library
end end
end end
# This is a hack to bypass a compiler bug # Helper function from src/util/util.cr
ary = titles sort_titles titles, opt.not_nil!, username
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end end
def deep_titles def deep_titles
@@ -127,7 +106,7 @@ 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, "" }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear } .tap { |_| @title_ids.clear }
+44 -16
View File
@@ -1,12 +1,14 @@
require "../archive" require "../archive"
class Title 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, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage, @entry_display_name_cache : Hash(String, String)?
@library : Library)
def initialize(@dir : String, @parent_id)
storage = Storage.default
id = storage.get_id @dir, true id = storage.get_id @dir, true
if id.nil? if id.nil?
id = random_str id = random_str
@@ -27,26 +29,26 @@ 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
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.default.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", ".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 @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
mtimes = [@mtime] 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 } mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max @mtime = mtimes.max
@title_ids.sort! do |a, b| @title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title, compare_numerically Library.default.title_hash[a].title,
@library.title_hash[b].title Library.default.title_hash[b].title
end end
sorter = ChapterSorter.new @entries.map { |e| e.title } sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b| @entries.sort! do |a, b|
@@ -82,7 +84,7 @@ class Title
end end
def titles def titles
@title_ids.map { |tid| @library.get_title! tid } @title_ids.map { |tid| Library.default.get_title! tid }
end end
# Get all entries, including entries in nested titles # Get all entries, including entries in nested titles
@@ -100,15 +102,37 @@ class Title
ary = [] of Title ary = [] of Title
tid = @parent_id tid = @parent_id
while !tid.empty? while !tid.empty?
title = @library.get_title! tid title = Library.default.get_title! tid
ary << title ary << title
tid = title.parent_id tid = title.parent_id
end end
ary.reverse ary.reverse
end end
def size # Returns a string the describes the content of the title
@entries.size + @title_ids.size # 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 end
def get_entry(eid) def get_entry(eid)
@@ -129,13 +153,17 @@ class Title
end end
def display_name(entry_name) def display_name(entry_name)
dn = entry_name unless @entry_display_name_cache
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]? @entry_display_name_cache = info.entry_display_name
end
end
dn = entry_name
info_dn = @entry_display_name_cache.not_nil![entry_name]?
unless info_dn.nil? || info_dn.empty? unless info_dn.nil? || info_dn.empty?
dn = info_dn dn = info_dn
end end
end
dn dn
end end
+13 -12
View File
@@ -3,11 +3,12 @@ require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "./plugin/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "tallboy"
MANGO_VERSION = "0.17.0" MANGO_VERSION = "0.18.1"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -53,6 +54,11 @@ class CLI < Clim
ARGV.clear ARGV.clear
Config.load(opts.config).set_current Config.load(opts.config).set_current
# Initialize main components
Storage.default
Queue.default
Library.default
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
@@ -105,18 +111,13 @@ class CLI < Clim
password.not_nil!, opts.admin password.not_nil!, opts.admin
when "list" when "list"
users = storage.list_users users = storage.list_users
name_length = users.map(&.[0].size).max? || 0 table = Tallboy.table do
l_cell_width = ["username".size, name_length].max header ["username", "admin access"]
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| users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \ row [name, admin]
"#{admin.to_s.ljust r_cell_width} "
end end
puts "-" * header.size end
puts table
when nil when nil
puts opts.help_string puts opts.help_string
else else
+42
View File
@@ -257,6 +257,48 @@ class Plugin
end end
sbx.put_prop_string -2, "get" 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| sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
+5 -1
View File
@@ -139,9 +139,13 @@ module Rename
post_process str post_process str
end 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) private def post_process(str)
return "_" if str == ".." return "_" if str == ".."
str.gsub "/", "_" str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
end end
end end
end end
+6 -8
View File
@@ -1,13 +1,11 @@
require "./router" struct AdminRouter
class AdminRouter < Router
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
layout "admin" layout "admin"
end end
get "/admin/user" do |env| get "/admin/user" do |env|
users = @context.storage.list_users users = Storage.default.list_users
username = get_username env username = get_username env
layout "user" layout "user"
end end
@@ -32,11 +30,11 @@ class AdminRouter < Router
# would not contain `admin` # would not contain `admin`
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
@context.storage.new_user username, password, admin Storage.default.new_user username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.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})
@@ -51,12 +49,12 @@ 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"]
@context.storage.update_user \ Storage.default.update_user \
original_username, username, password, admin original_username, username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.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, \
+111 -37
View File
@@ -1,9 +1,8 @@
require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa" require "koa"
class APIRouter < Router struct APIRouter
@@api_json : String? @@api_json : String?
API_VERSION = "0.1.0" API_VERSION = "0.1.0"
@@ -153,6 +152,7 @@ class APIRouter < Router
Koa.object "dimensionResult", { Koa.object "dimensionResult", {
"success" => "boolean", "success" => "boolean",
"dimensions" => "$dimensionAry?", "dimensions" => "$dimensionAry?",
"margin" => "number",
"error" => "string?", "error" => "string?",
} }
@@ -160,6 +160,12 @@ class APIRouter < Router
"ids" => "$strAry", "ids" => "$strAry",
} }
Koa.object "tagsResult", {
"success" => "boolean",
"tags" => "$strAry?",
"error" => "string?",
}
Koa.describe "Returns a page in a manga entry" Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
@@ -172,7 +178,7 @@ class APIRouter < Router
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i 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? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -182,7 +188,7 @@ class APIRouter < Router
send_img env, img send_img env, img
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
@@ -198,7 +204,7 @@ class APIRouter < Router
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] 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? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -209,7 +215,7 @@ class APIRouter < Router
send_img env, img send_img env, img
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
@@ -222,12 +228,12 @@ class APIRouter < Router
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] 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? raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
e.message e.message
end end
@@ -236,7 +242,7 @@ class APIRouter < Router
Koa.describe "Returns the entire library with all titles and entries" Koa.describe "Returns the entire library with all titles and entries"
Koa.response 200, ref: "$library" Koa.response 200, ref: "$library"
get "/api/library" do |env| get "/api/library" do |env|
send_json env, @context.library.to_json send_json env, Library.default.to_json
end end
Koa.describe "Triggers a library scan" Koa.describe "Triggers a library scan"
@@ -244,11 +250,11 @@ class APIRouter < Router
Koa.response 200, ref: "$scanResult" Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
@context.library.scan Library.default.scan
ms = (Time.utc - start).total_milliseconds ms = (Time.utc - start).total_milliseconds
send_json env, { send_json env, {
"milliseconds" => ms, "milliseconds" => ms,
"titles" => @context.library.titles.size, "titles" => Library.default.titles.size,
}.to_json }.to_json
end end
@@ -275,9 +281,9 @@ class APIRouter < Router
delete "/api/admin/user/delete/:username" do |env| delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username Storage.default.delete_user username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -302,7 +308,7 @@ class APIRouter < Router
put "/api/progress/:tid/:page" do |env| put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env 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 page = env.params.url["page"].to_i
entry_id = env.params.query["eid"]? entry_id = env.params.query["eid"]?
@@ -316,7 +322,7 @@ class APIRouter < Router
title.read_all username title.read_all username
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -334,7 +340,7 @@ class APIRouter < Router
put "/api/bulk_progress/:action/:tid" do |env| put "/api/bulk_progress/:action/:tid" do |env|
begin begin
username = get_username env 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"] action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s ids = env.params.json["ids"].as(Array).map &.as_s
@@ -343,7 +349,7 @@ class APIRouter < Router
end end
title.bulk_progress action, ids, username title.bulk_progress action, ids, username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -363,7 +369,7 @@ class APIRouter < Router
Koa.response 200, ref: "$result" Koa.response 200, ref: "$result"
put "/api/admin/display_name/:tid/:name" do |env| put "/api/admin/display_name/:tid/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["tid"]) title = (Library.default.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["eid"]? entry = env.params.query["eid"]?
@@ -374,7 +380,7 @@ class APIRouter < Router
title.set_display_name eobj.not_nil!.title, name title.set_display_name eobj.not_nil!.title, name
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -397,7 +403,7 @@ class APIRouter < Router
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
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
@@ -421,13 +427,13 @@ class APIRouter < Router
Time.unix chapter["time"].as_s.to_i Time.unix chapter["time"].as_s.to_i
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
}.to_json }.to_json
rescue e rescue e
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
@@ -437,8 +443,8 @@ class APIRouter < Router
interval = (interval_raw.to_i? if interval_raw) || 5 interval = (interval_raw.to_i? if interval_raw) || 5
loop do loop do
socket.send({ socket.send({
"jobs" => @context.queue.get_all, "jobs" => Queue.default.get_all,
"paused" => @context.queue.paused?, "paused" => Queue.default.paused?,
}.to_json) }.to_json)
sleep interval.seconds sleep interval.seconds
end end
@@ -451,10 +457,10 @@ class APIRouter < Router
Koa.response 200, ref: "$jobs" Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = @context.queue.get_all jobs = Queue.default.get_all
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => jobs,
"paused" => @context.queue.paused?, "paused" => Queue.default.paused?,
"success" => true, "success" => true,
}.to_json }.to_json
rescue e rescue e
@@ -485,20 +491,20 @@ class APIRouter < Router
case action case action
when "delete" when "delete"
if id.nil? if id.nil?
@context.queue.delete_status Queue::JobStatus::Completed Queue.default.delete_status Queue::JobStatus::Completed
else else
@context.queue.delete id Queue.default.delete id
end end
when "retry" when "retry"
if id.nil? if id.nil?
@context.queue.reset Queue.default.reset
else else
@context.queue.reset id Queue.default.reset id
end end
when "pause" when "pause"
@context.queue.pause Queue.default.pause
when "resume" when "resume"
@context.queue.resume Queue.default.resume
else else
raise "Unknown queue action #{action}" raise "Unknown queue action #{action}"
end end
@@ -544,7 +550,7 @@ class APIRouter < Router
when "cover" when "cover"
title_id = env.params.query["tid"] title_id = env.params.query["tid"]
entry_id = env.params.query["eid"]? 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? \ unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename MIME.from_filename? filename
@@ -628,7 +634,7 @@ class APIRouter < Router
Time.utc Time.utc
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
@@ -650,7 +656,7 @@ class APIRouter < Router
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] 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? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -659,6 +665,7 @@ class APIRouter < Router
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
"margin" => Config.current.page_margin,
}.to_json }.to_json
rescue e rescue e
send_json env, { send_json env, {
@@ -675,16 +682,83 @@ class APIRouter < Router
Koa.response 404, "Entry not found" Koa.response 404, "Entry not found"
get "/api/download/:tid/:eid" do |env| get "/api/download/:tid/:eid" do |env|
begin 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! entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path send_attachment env, entry.zip_path
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
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 doc = Koa.generate
@@api_json = doc.to_json if doc @@api_json = doc.to_json if doc
+56 -17
View File
@@ -1,6 +1,4 @@
require "./router" struct MainRouter
class MainRouter < Router
def initialize def initialize
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url base_url = Config.current.base_url
@@ -11,7 +9,7 @@ class MainRouter < Router
begin begin
env.session.delete_string "token" env.session.delete_string "token"
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" Logger.error "Error when attempting to log out: #{e}"
ensure ensure
redirect env, "/login" redirect env, "/login"
end end
@@ -21,7 +19,7 @@ class MainRouter < Router
begin begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] 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 env.session.string "token", token
@@ -41,22 +39,22 @@ class MainRouter < Router
begin begin
username = get_username env 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 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 percentage = titles.map &.load_percentage username
layout "library" layout "library"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/book/:title" do |env| get "/book/:title" do |env|
begin 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 username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username sort_opt = SortOptions.from_info_json title.dir, username
@@ -68,7 +66,7 @@ class MainRouter < Router
title_percentage = title.titles.map &.load_percentage username title_percentage = title.titles.map &.load_percentage username
layout "title" layout "title"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -92,7 +90,7 @@ class MainRouter < Router
layout "plugin-download" layout "plugin-download"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -100,20 +98,61 @@ class MainRouter < Router
get "/" do |env| get "/" do |env|
begin begin
username = get_username env username = get_username env
continue_reading = @context continue_reading = Library.default
.library.get_continue_reading_entries username .get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username recently_added = Library.default.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username start_reading = Library.default.get_start_reading_titles username
titles = @context.library.titles titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0 empty_library = titles.size == 0
layout "home" layout "home"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
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| get "/api" do |env|
render "src/views/api.html.ecr" render "src/views/api.html.ecr"
end end
+4 -6
View File
@@ -1,18 +1,16 @@
require "./router" struct OPDSRouter
class OPDSRouter < Router
def initialize def initialize
get "/opds" do |env| get "/opds" do |env|
titles = @context.library.titles titles = Library.default.titles
render_xml "src/views/opds/index.xml.ecr" render_xml "src/views/opds/index.xml.ecr"
end end
get "/opds/book/:title_id" do |env| get "/opds/book/:title_id" do |env|
begin 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" render_xml "src/views/opds/title.xml.ecr"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
+12 -12
View File
@@ -1,25 +1,23 @@
require "./router" struct ReaderRouter
class ReaderRouter < Router
def initialize def initialize
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
username = get_username env 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! entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg next layout "reader-error" if entry.err_msg
# load progress # 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 # 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 rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
@@ -30,10 +28,12 @@ class ReaderRouter < Router
username = get_username env 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! entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i page_idx = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 if page_idx > entry.pages || page_idx <= 0
raise "Page #{page_idx} not found."
end
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
@@ -45,7 +45,7 @@ class ReaderRouter < Router
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
-3
View File
@@ -1,3 +0,0 @@
class Router
@context : Context = Context.default
end
+3 -25
View File
@@ -5,29 +5,7 @@ require "./handlers/*"
require "./util/*" require "./util/*"
require "./routes/*" 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 class Server
@context : Context = Context.default
def initialize 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}"
@@ -53,11 +31,11 @@ class Server
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
add_handler AuthHandler.new @context.storage add_handler AuthHandler.new
add_handler UploadHandler.new Config.current.upload_path add_handler UploadHandler.new Config.current.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." Logger.debug "We are in release mode. Using embedded static files."
serve_static false serve_static false
add_handler StaticHandler.new add_handler StaticHandler.new
{% end %} {% end %}
@@ -71,7 +49,7 @@ class Server
end end
def start def start
@context.debug "Starting Kemal server" Logger.debug "Starting Kemal server"
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
+111
View File
@@ -35,16 +35,24 @@ class Storage
MainFiber.run do MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin 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 " \ db.exec "create table thumbnails " \
"(id text, data blob, filename text, " \ "(id text, data blob, filename text, " \
"mime text, size integer)" "mime text, size integer)"
db.exec "create unique index tn_index on thumbnails (id)" db.exec "create unique index tn_index on thumbnails (id)"
# v0.1.1
db.exec "create table ids" \ db.exec "create table ids" \
"(path text, id text, is_title integer)" "(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)" db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)" db.exec "create unique index id_idx on ids (id)"
# v0.1.0
db.exec "create table users" \ db.exec "create table users" \
"(username text, password text, token text, admin integer)" "(username text, password text, token text, admin integer)"
rescue e rescue e
@@ -64,6 +72,14 @@ class Storage
init_admin if init_user init_admin if init_user
end 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 end
unless @auto_close unless @auto_close
@db = DB.open "sqlite3://#{@path}" @db = DB.open "sqlite3://#{@path}"
@@ -90,6 +106,28 @@ class Storage
end end
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) def verify_user(username, password)
out_token = nil out_token = nil
MainFiber.run do MainFiber.run do
@@ -266,6 +304,70 @@ class Storage
img img
end 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 def optimize
MainFiber.run do MainFiber.run do
Logger.info "Starting DB optimization" Logger.info "Starting DB optimization"
@@ -292,6 +394,15 @@ class Storage
db.exec "delete from thumbnails where id not in (select id from ids)" db.exec "delete from thumbnails where id not in (select id from ids)"
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted" Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
end 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 end
Logger.info "DB optimization finished" Logger.info "DB optimization finished"
end end
+25
View File
@@ -67,3 +67,28 @@ def env_is_true?(key : String) : Bool
return false unless val return false unless val
val.downcase.in? "1", "true" val.downcase.in? "1", "true"
end 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
+20 -11
View File
@@ -4,14 +4,22 @@ macro layout(name)
base_url = Config.current.base_url base_url = Config.current.base_url
begin begin
is_admin = false 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" if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token is_admin = Storage.default.verify_admin token
end end
page = {{name}} page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e rescue e
message = e.to_s message = e.to_s
@context.error message Logger.error message
page = "Error"
render "src/views/message.html.ecr", "src/views/layout.html.ecr" render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end end
end end
@@ -21,10 +29,16 @@ macro send_img(env, img)
end end
macro get_username(env) macro get_username(env)
# if the request gets here, it has gone through the auth handler, and begin
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token" token = env.session.string "token"
(@context.storage.verify_token token).not_nil! (Storage.default.verify_token token).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
else
raise e
end
end
end end
def send_json(env, json) def send_json(env, json)
@@ -46,12 +60,7 @@ def hash_to_query(hash)
end end
def request_path_startswith(env, ary) def request_path_startswith(env, ary)
ary.each do |prefix| ary.any? { |prefix| env.request.path.starts_with? prefix }
if env.request.path.starts_with? prefix
return true
end
end
false
end end
def requesting_static_file(env) def requesting_static_file(env)
+10 -6
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}"> <ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li> <li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li :class="{'nopointer' : scanning}" @click="scan()"> <li>
<a class="uk-link-reset" @click="scan()">
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span> <span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<div class="uk-align-right"> <div class="uk-align-right">
<div uk-spinner x-show="scanning"></div> <div uk-spinner x-show="scanning"></div>
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span> <span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</div> </div>
</a>
</li> </li>
<li :class="{'nopointer' : generating}" @click="generateThumbnails()"> <li>
<a class="uk-link-reset" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span> <span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right"> <div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span> <span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div> </div>
</a>
</li> </li>
<li class="nopointer"> <li>
<span>Theme</span> <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>Dark</option>
<option>Light</option> <option>Light</option>
<option>System</option> <option>System</option>
+3 -2
View File
@@ -35,7 +35,7 @@
onclick="location='<%= base_url %>book/<%= item.id %>'" onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>> <% end %>>
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}" <div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true, selecting: false}" :class="{selected: selected}" @count.window="selecting = $event.detail.count > 0"
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %> <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
x-init="disabled = false" x-init="disabled = false"
<% end %>> <% end %>>
@@ -45,6 +45,7 @@
class="grayscale" class="grayscale"
<% end %>> <% end %>>
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)"> <div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
<div class="uk-height-1-1 uk-width-1-1" x-show="selecting" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')"></div>
<div class="uk-position-center"> <div class="uk-position-center">
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span> <span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
</div> </div>
@@ -75,7 +76,7 @@
<% end %> <% end %>
<% if item.is_a? Title %> <% if item.is_a? Title %>
<% if grouped_count == 1 %> <% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.size %> entries</p> <p class="uk-text-meta"><%= item.content_label %></p>
<% else %> <% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p> <p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %> <% end %>
+3 -3
View File
@@ -1,7 +1,7 @@
<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 - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader"> <meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" /> <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/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script> <script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></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.5.0/dist/alpine-ie11.min.js" defer></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> <script src="<%= base_url %>js/common.js"></script>
</head> </head>
+12
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>
+3 -3
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"> <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('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed 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 x-text="`${job.plugin_id || ''}`"></td>
<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"> <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> </template>
</td> </td>
</tr> </tr>
+2
View File
@@ -11,6 +11,7 @@
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav> <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 %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent"> <li class="uk-parent">
@@ -40,6 +41,7 @@
<ul class="uk-navbar-nav"> <ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li> <li>
+1
View File
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<% page = "Login" %>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
+1 -3
View File
@@ -21,9 +21,7 @@
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
UIkit.modal('#modal').show().then(function() { UIkit.modal('#modal').show();
styleModal();
});
UIkit.util.on('#modal', 'hide', function() { UIkit.util.on('#modal', 'hide', function() {
location.href = "<%= base_url %>book/<%= entry.book.id %>"; location.href = "<%= base_url %>book/<%= entry.book.id %>";
}); });
+12 -21
View File
@@ -1,21 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="reader-bg"> <html class="reader-bg">
<% page = "Reader" %>
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;"> <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" <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
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
}">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@@ -35,17 +25,18 @@
<img <img
uk-img uk-img
class="uk-align-center" class="uk-align-center"
:style="item.style"
:data-src="item.url" :data-src="item.url"
:width="item.width" :width="item.width"
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:onclick="`showControl('${item.id}')`" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- 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> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%> <%- 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 -%> <%- end -%>
</div> </div>
@@ -55,7 +46,7 @@
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
@@ -82,7 +73,7 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to page</label>
<div class="uk-form-controls"> <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| -%> <%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option> <option value="<%= p %>"><%= p %></option>
<%- end -%> <%- end -%>
@@ -92,7 +83,7 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label> <label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls"> <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="continuous">Continuous</option>
<option value="paged">Paged</option> <option value="paged">Paged</option>
</select> </select>
@@ -100,14 +91,14 @@
</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" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>"; const base_url = "<%= base_url %>";
const page = <%= page %>; const page = <%= page_idx %>;
const tid = "<%= title.id %>"; const tid = "<%= title.id %>";
const eid = "<%= entry.id %>"; const eid = "<%= entry.id %>";
</script> </script>
@@ -120,7 +111,7 @@
<style> <style>
img[data-src][src*='data:image'] { background: white; } img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; } img { width: 100%; }
</style> </style>
</html> </html>
+30
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
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 %>
+5 -2
View File
@@ -1,5 +1,5 @@
<div> <div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>"> <div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++; $dispatch('count', {count: count})" @remove.window="count--; $dispatch('count', {count: count})" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid> <div class="uk-child-width-1-3" uk-grid>
<div> <div>
<p x-text="count + ' items selected'" style="color:orange"></p> <p x-text="count + ' items selected'" style="color:orange"></p>
@@ -32,7 +32,10 @@
<%- 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.content_label %> found</p>
<%= render_component "tags" %>
<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">