295 lines
7.7 KiB
Python
295 lines
7.7 KiB
Python
|
import argparse
|
||
|
import bs4
|
||
|
import epubfile
|
||
|
import flask; from flask import request
|
||
|
import hashlib
|
||
|
import jinja2
|
||
|
import mimetypes
|
||
|
import sys
|
||
|
|
||
|
from voussoirkit import betterhelp
|
||
|
from voussoirkit import cacheclass
|
||
|
from voussoirkit import pathclass
|
||
|
from voussoirkit import vlogging
|
||
|
|
||
|
log = vlogging.get_logger(__name__, 'epubserver')
|
||
|
|
||
|
books = cacheclass.Cache(maxlen=100)
|
||
|
|
||
|
site = flask.Flask(__name__)
|
||
|
|
||
|
def md5_string(s):
|
||
|
return hashlib.md5(s.encode('utf-8')).hexdigest()
|
||
|
|
||
|
def navpoint_to_li(soup, navpoint, srcbase, depth):
|
||
|
li = soup.new_tag('li')
|
||
|
a = soup.new_tag('a')
|
||
|
a['href'] = '/' + srcbase.join(navpoint.content['src']).absolute_path
|
||
|
a['class'] = f'toc_depth_{depth}'
|
||
|
a['data-depth'] = str(depth)
|
||
|
a.string = navpoint.navLabel.text.strip()
|
||
|
children = list(navpoint.find_all('navPoint', recursive=False))
|
||
|
if children:
|
||
|
details = soup.new_tag('details')
|
||
|
summary = soup.new_tag('summary')
|
||
|
summary.append(a)
|
||
|
details.append(summary)
|
||
|
li.append(details)
|
||
|
ol = soup.new_tag('ol')
|
||
|
ol['class'] = f'toc_depth_{depth+1}'
|
||
|
for navpoint in children:
|
||
|
ol.append(navpoint_to_li(soup, navpoint, srcbase, depth+1))
|
||
|
details.append(ol)
|
||
|
else:
|
||
|
li.append(a)
|
||
|
return li
|
||
|
|
||
|
def prepare_reader(book):
|
||
|
reader = bs4.BeautifulSoup('''
|
||
|
<!DOCTYPE html>
|
||
|
<html id="reader_html">
|
||
|
<head>
|
||
|
<style>
|
||
|
#reader_html, #reader_html > body
|
||
|
{
|
||
|
margin: 0;
|
||
|
height: 100vh;
|
||
|
width: 100vw;
|
||
|
box-sizing:border-box;
|
||
|
}
|
||
|
*
|
||
|
{
|
||
|
box-sizing: inherit;
|
||
|
}
|
||
|
|
||
|
body
|
||
|
{
|
||
|
position: relative;
|
||
|
}
|
||
|
#table_of_contents
|
||
|
{
|
||
|
position: absolute;
|
||
|
overflow-y:auto;
|
||
|
left:0;
|
||
|
top:0;
|
||
|
bottom:0;
|
||
|
width:400px;
|
||
|
background-color: lightgray;
|
||
|
}
|
||
|
#table_of_contents .current_chapter
|
||
|
{
|
||
|
font-weight: bold;
|
||
|
}
|
||
|
#iframe_holder
|
||
|
{
|
||
|
position:fixed;
|
||
|
top:0;
|
||
|
bottom:0;
|
||
|
right:0;
|
||
|
left:400px;
|
||
|
}
|
||
|
#reader_iframe
|
||
|
{
|
||
|
width: 100%;
|
||
|
height:100%;
|
||
|
margin:0;
|
||
|
border:0;
|
||
|
}
|
||
|
</style>
|
||
|
</head>
|
||
|
<body>
|
||
|
<section id="table_of_contents">
|
||
|
<ol>
|
||
|
</ol>
|
||
|
</section>
|
||
|
<div id="iframe_holder"><iframe id="reader_iframe"></iframe></div>
|
||
|
</body>
|
||
|
|
||
|
<script>
|
||
|
const TOC = document.getElementById("table_of_contents");
|
||
|
const IFRAME = document.getElementById("reader_iframe");
|
||
|
const BOOK_ID = "{BOOK_ID}";
|
||
|
let current_chapter = null;
|
||
|
|
||
|
function toc_unbold_all()
|
||
|
{
|
||
|
current_chapter = null;
|
||
|
for (const a of TOC.getElementsByTagName("a"))
|
||
|
{
|
||
|
a.classList.remove("current_chapter")
|
||
|
}
|
||
|
}
|
||
|
function toc_identify_current_chapter()
|
||
|
{
|
||
|
toc_unbold_all();
|
||
|
for (const a of TOC.getElementsByTagName("a"))
|
||
|
{
|
||
|
if (a.href == IFRAME.src)
|
||
|
{
|
||
|
current_chapter = a;
|
||
|
a.classList.add("current_chapter");
|
||
|
let x = a;
|
||
|
while (x !== null)
|
||
|
{
|
||
|
const details = x.parentElement.closest("details");
|
||
|
if (details !== null)
|
||
|
{
|
||
|
details.open = true;
|
||
|
}
|
||
|
x = details;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function toc_onclick(event)
|
||
|
{
|
||
|
if (event.which !== 1)
|
||
|
{
|
||
|
return;
|
||
|
}
|
||
|
if (event.target.tagName === "A")
|
||
|
{
|
||
|
IFRAME.src = event.target.href;
|
||
|
localStorage.setItem(BOOK_ID + ".leftoff_page", event.target.href);
|
||
|
if (current_chapter !== null)
|
||
|
{
|
||
|
current_chapter.classList.remove("current_chapter");
|
||
|
event.target.classList.add("current_chapter");
|
||
|
}
|
||
|
current_chapter = event.target;
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
}
|
||
|
}
|
||
|
function on_pageload()
|
||
|
{
|
||
|
TOC.addEventListener("click", toc_onclick);
|
||
|
const leftoff_page = (localStorage.getItem(BOOK_ID + ".leftoff_page") || null);
|
||
|
if (leftoff_page !== null)
|
||
|
{
|
||
|
IFRAME.src = leftoff_page;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
const first_page = TOC.querySelector("a").href;
|
||
|
IFRAME.src = first_page;
|
||
|
localStorage.setItem(BOOK_ID + ".leftoff_page", first_page);
|
||
|
}
|
||
|
toc_identify_current_chapter();
|
||
|
}
|
||
|
document.addEventListener("DOMContentLoaded", on_pageload);
|
||
|
</script>
|
||
|
</html>
|
||
|
'''.replace('{BOOK_ID}', md5_string(book.root_directory.normcase)), 'html.parser')
|
||
|
|
||
|
ncx_id = book.get_ncx()
|
||
|
toc = reader.find('section', {'id': 'table_of_contents'})
|
||
|
if ncx_id:
|
||
|
ncx_filepath = book.get_filepath(ncx_id)
|
||
|
ncx = book.read_file(ncx_id, soup=True)
|
||
|
srcbase = ncx_filepath.parent
|
||
|
for navpoint in list(ncx.navMap.find_all('navPoint', recursive=False)):
|
||
|
toc.ol.append(navpoint_to_li(reader, navpoint, srcbase, depth=1))
|
||
|
|
||
|
book._reader = reader
|
||
|
|
||
|
def get_book(epub_path):
|
||
|
if epub_path in books:
|
||
|
return books[epub_path]
|
||
|
book = epubfile.Epub(epub_path, read_only=True)
|
||
|
book._epubserver_manifest = book.get_texts(soup=True, skip_nav=True)
|
||
|
print(book._epubserver_manifest)
|
||
|
book._epubserver_manifest_ids = [x['id'] for x in book._epubserver_manifest]
|
||
|
book._epubserver_reverse_manifest = {
|
||
|
book.get_filepath(item['id']): item
|
||
|
for item in book._epubserver_manifest
|
||
|
}
|
||
|
prepare_reader(book)
|
||
|
books[epub_path] = book
|
||
|
return book
|
||
|
|
||
|
def epubserver_flask(port, *args, **kwargs):
|
||
|
@site.route('/')
|
||
|
def root():
|
||
|
response = jinja2.Template('''
|
||
|
<html>
|
||
|
<body>
|
||
|
</body>
|
||
|
</html>
|
||
|
''').render()
|
||
|
return response
|
||
|
|
||
|
@site.route('/<path:path>')
|
||
|
def mainroute(path):
|
||
|
if path.endswith('.epub'):
|
||
|
path += '/'
|
||
|
|
||
|
try:
|
||
|
ix = path.lower().index('.epub/')
|
||
|
except ValueError:
|
||
|
return flask.abort(404, 'URL does not contain epub.')
|
||
|
else:
|
||
|
epub_path = path[:ix+5]
|
||
|
request_path = path[ix+6:]
|
||
|
|
||
|
try:
|
||
|
book = get_book(epub_path)
|
||
|
except FileNotFoundError:
|
||
|
return flask.abort(404, 'Could not open book.')
|
||
|
|
||
|
if request_path == '':
|
||
|
return str(book._reader)
|
||
|
|
||
|
if request_path in book._epubserver_manifest:
|
||
|
return book.read_file(request_path)
|
||
|
|
||
|
filepath = book.root_directory.join(request_path)
|
||
|
if filepath not in book.root_directory:
|
||
|
return flask.abort(404)
|
||
|
|
||
|
try:
|
||
|
log.debug('Trying filepath %s', filepath)
|
||
|
content = book._fopen(filepath, 'rb').read()
|
||
|
except Exception:
|
||
|
return flask.abort(404)
|
||
|
|
||
|
manifest_item = book._epubserver_reverse_manifest.get(filepath)
|
||
|
if manifest_item is not None:
|
||
|
mime = manifest_item['media-type']
|
||
|
else:
|
||
|
mime = mimetypes.guess_type(request_path)[0]
|
||
|
|
||
|
response = flask.make_response(content)
|
||
|
if mime:
|
||
|
response.headers['Content-Type'] = mime
|
||
|
else:
|
||
|
response.headers['Content-Type'] = None
|
||
|
|
||
|
response.headers['Cache-Control'] = 'max-age=600'
|
||
|
|
||
|
return response
|
||
|
|
||
|
site.run(host='0.0.0.0', port=port)
|
||
|
|
||
|
def epubserver_argparse(args):
|
||
|
epubserver_flask(args.port)
|
||
|
return 0
|
||
|
|
||
|
@vlogging.main_decorator
|
||
|
def main(argv):
|
||
|
parser = argparse.ArgumentParser(
|
||
|
description='''
|
||
|
''',
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
'port',
|
||
|
type=int,
|
||
|
)
|
||
|
parser.set_defaults(func=epubserver_argparse)
|
||
|
|
||
|
return betterhelp.go(parser, argv)
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
raise SystemExit(main(sys.argv[1:]))
|