#!/usr/bin/env python3
import bottle
import datetime
import dateutil
import functools
import jinja2
import os
import pathlib
import pyld
import re
import requests
from bottle import jinja2_template as template, request, response
from string import Template
# Check that we have an endpoint for querying
FUSEKI_ENDPOINT = os.environ.get('FUSEKI_ENDPOINT')
if not FUSEKI_ENDPOINT:
raise Exception('FUSEKI_ENDPOINT environment variable not set.')
# This only exists for exporting the bottle app object for a WSGI server such as Gunicorn
application = bottle.app()
# Directories to search for HTML templates
bottle.TEMPLATE_PATH = [ './pages' ]
def query(query_string, jsonld_frame=None):
"""
Send query to Fuseki via HTTP.
Return the query results.
"""
http_request = requests.post(
FUSEKI_ENDPOINT,
data = { 'format': 'json', 'query': query_string})
results = http_request.json()
if jsonld_frame:
results = pyld.jsonld.frame(results, jsonld_frame, options={'omitGraph':False})
return results
def human_size(bytes, B=False):
"""
Convert a file size in bytes to a human friendly form.
This is only used in templates when showing file sizes.
"""
for unit in [ 'B' if B else '', 'K', 'M', 'G', 'T', 'P' ]:
if bytes < 1024: break
bytes = bytes / 1024
return '{}{}'.format(round(bytes), unit).rjust(5)
template = functools.partial(template, template_settings = {
'filters': {
'datetime': lambda date: dateutil.parser.parse(date).strftime('%b %-d, %Y - %H:%M%z%Z'),
'human_size': human_size
},
'globals': {
'now': lambda: datetime.datetime.now(datetime.timezone.utc),
'query': query,
'request': request,
'url': application.get_url,
},
'autoescape': True
})
@bottle.error(404)
def error404(error):
"""
Custom 404 page.
:param error: bottle.HTTPError given by Bottle when calling abort(404).
"""
return '[404] {}'.format(error.body)
@bottle.get('/favicon.ico')
def favicon():
return bottle.static_file('favicon.ico', root='./')
@bottle.get('/robots.txt')
def robotstxt():
response.content_type = 'text/plain; charset=UTF8'
return """User-agent: *
Disallow:
"""
@bottle.get('/static/<filename:path>', name='static')
def static(filename):
"""
Path for serving static files.
"""
return bottle.static_file(filename, root='./static/')
@bottle.route('/library', method=['GET', 'POST'], name='library')
def library():
"""
Library index
"""
# Get a list of authors for searching
authors = query("""
PREFIX library: <dokk:library:>
PREFIX license: <dokk:license:>
SELECT DISTINCT ?name
WHERE {
[] library:author ?name
}
ORDER BY ?name
""")['results']['bindings']
# Get a list of licenses for searching
licenses = query("""
PREFIX library: <dokk:library:>
PREFIX license: <dokk:license:>
SELECT DISTINCT ?id
WHERE {
[] library:license [
license:id ?id
] .
}
ORDER BY ?id
""")['results']['bindings']
# Retrieve filters selected by the user
filters_author = []
filters_license = []
query_filters = ''
if request.method == 'POST':
filters_author = request.forms.getall('author')
filters_license = request.forms.getall('license')
if len(filters_author) > 0:
query_filters_author = ','.join([ '"'+i.replace('"', '\\"')+'"' for i in filters_author ])
query_filters += f'FILTER(?author IN ({query_filters_author}))'
if len(filters_license) > 0:
query_filters_license = ','.join([ '"'+i.replace('"', '\\"')+'"' for i in filters_license ])
query_filters += f'FILTER(?license_id IN ({query_filters_license}))'
items = query(f"""
PREFIX library: <dokk:library:>
PREFIX license: <dokk:license:>
CONSTRUCT {{
?item library:title ?title;
library:author ?author ;
library:license ?license .
?license license:id ?license_id ;
license:name ?license_name .
}}
WHERE {{
?item library:title ?title ;
library:author ?author ;
library:license ?license .
OPTIONAL {{
?license license:id ?license_id_optional ;
license:name ?license_name_optional .
}}
BIND(COALESCE(?license_id_optional, SUBSTR(STR(?license), 14)) AS ?license_id)
BIND(COALESCE(?license_name_optional, SUBSTR(STR(?license), 14)) AS ?license_name)
{query_filters}
}}
ORDER BY UCASE(?title)
""",
{
'@context': {
'library': 'dokk:library:',
'license': 'dokk:license:',
'library:author': { '@container': '@set' },
'library:license': { '@container': '@set' }
},
'library:title': {}
})
return template('library.html', authors=authors, licenses=licenses, items=items,
filters_author=filters_author, filters_license=filters_license)
@bottle.get('/library/opds.xml', name='library_opds')
def library_opds():
"""
Serve OPDS Atom RSS feeds
"""
#response.content_type = 'text/xml; charset=utf-8'
return template('templates/library/opds/root.tpl')
@bottle.get('/library/opds/books.xml', name='library_opds_books')
def library_opds_books():
"""
Serve OPDS Atom RSS feeds.
Items of type "books" only
"""
#response.content_type = 'text/xml; charset=utf-8'
return template('templates/library/opds/books.tpl')
@bottle.get('/library/opds/others.xml', name='library_opds_others')
def library_opds_others():
"""
Serve OPDS Atom RSS feeds.
Uncategorized items only
"""
#response.content_type = 'text/xml; charset=utf-8'
return template('templates/library/opds/others.tpl')
@bottle.get('/library/<item_id>', name='library_item')
def library_item(item_id):
"""
"""
try:
with open(f'../library_txt/{item_id}.txt', 'r') as file:
item_plaintext = file.read()
except:
item_plaintext = ''
return template('templates/library/item.tpl', item_id=item_id, plaintext=item_plaintext)
@bottle.get('/license', name='license_list')
def license_list():
"""
List all licenses.
"""
# TODO sanitize input
data = query(f"""
PREFIX license: <dokk:vocab:license:>
SELECT *
WHERE {{
?license license:id ?id ;
license:name ?name .
}}
ORDER BY ASC(UCASE(?name))
""")
return template('license.html', data=data)
@bottle.get('/license/<id>', name='license')
def license(id):
"""
Show a single license
"""
# TODO sanitize input
data = query(f"""
PREFIX license: <dokk:vocab:license:>
DESCRIBE <dokk:license:{id}>
""")
return template('templates/license/license.tpl', data=data)
@bottle.get('/manpages/<distribution>/<version>', name='manpages_distribution')
def manpages_distribution(distribution, version):
"""
List packages by distribution.
"""
data = query(
f'''
PREFIX mp: <dokk:manpages:>
SELECT DISTINCT ?name
WHERE {{
[] a mp:Distribution ;
mp:name "{distribution}" ;
mp:number {version} ;
mp:package ?package .
?package a mp:Package ;
mp:name ?name .
}}
ORDER BY ?name
''')
return template('templates/manpages/distribution.tpl', data=data, distribution=distribution, version=version)
@bottle.get('/manpages/<distribution>/<version>/<package>', name='manpages_package')
def manpages_package(distribution, version, package):
"""
List manpages by package.
"""
data = query(
f'''
PREFIX mp: <dokk:manpages:>
SELECT (?page as ?id) ?filename
WHERE {{
[] a mp:Distribution ;
mp:name "{distribution}" ;
mp:number {version} ;
mp:package ?package .
?package a mp:Package ;
mp:name "{package}" ;
mp:page ?page .
?page a mp:Page ;
mp:filename ?filename
}}
ORDER BY ?filename
''')
return template('templates/manpages/package.tpl', data=data, distribution=distribution, version=version, package=package)
@bottle.get('/manpages/<name>.<section>', name='manpages_disambiguation')
def manpages_disambiguation(name, section):
"""
Show a list of manpages that match <name> and <section>
"""
data = query(
f'''
PREFIX mp: <dokk:manpages:>
SELECT *
WHERE {{
?page a mp:Page ;
mp:filename ?filename ;
mp:name_lowercase "{name}" ;
mp:section_lowercase "{section}" .
?package a mp:Package ;
mp:name ?package_name ;
mp:page ?page .
?distro a mp:Distribution ;
mp:name ?distro_name ;
mp:number ?distro_number ;
mp:package ?package .
}}
ORDER BY ?distro_name ?distro_number ?package_name
''')['results']['bindings']
return template('templates/manpages/disambiguation.tpl', name=name, section=section, data=data)
@bottle.get('/manpages/<distro_name>/<distro_number>/<package>/<page>', name='manpages_page')
def manpages_page(distro_name, distro_number, package, page):
"""
Display a single manpage.
"""
data = query(
f'''
PREFIX mp: <dokk:manpages:>
SELECT *
WHERE {{
?page a mp:Page ;
mp:filename "{page}" ;
mp:name ?page_name ;
mp:section ?page_section ;
mp:html ?page_html .
?package a mp:Package ;
mp:name "{package}" ;
mp:page ?page .
?distro a mp:Distribution ;
mp:name "{distro_name}" ;
mp:number {distro_number} ;
mp:package ?package .
}}
''')['results']['bindings'][0]
"""
Replace references to other manpages with links.
Manpages have a section "See also" at the bottom where they suggest other pages.
For example:
SEE ALSO
cdk(3), cdk_screen(3), cdk_display(3), cdk_binding(3), cdk_util(3)
We convert these strings to links to other pages.
Example: ls(1) -> <a href="...">ls(1)</a>
regex explanation:
- some manpages use a bold or italic name, so we match an optional <b>
<i> tag
- match valid characters for a manpage, assign name <page>
- match optional closing </em> or </strong> tags
- match '('
- match number, assign name <section>
- match ')'
"""
html = data['page_html']['value']
html = re.sub (
'(?:<b>|<i>)?(?P<page>[\w_.:-]+)(?:</b>|</i>)?\((?P<section>[0-9]\w*)\)',
lambda match:
f'''<a href="{application.get_url('manpages_disambiguation',
name=match.group('page'), section=match.group('section')).lower()}"
>{match.group('page')}({match.group('section')})</a>''',
html)
return template('templates/manpages/manpage.tpl',
distro = {'name': distro_name, 'number': distro_number},
package = package,
page = {
'filename': page,
'name': data['page_name']['value'],
'section': data['page_section']['value'],
'html': html,
})
@bottle.get('/mimi_and_eunice/<number>', name='mimi_and_eunice_strip')
def mimi_and_eunice_strip(number):
"""
"""
iri = '<dokk:mimi_and_eunice:' + number + '>'
data = query(Template(
'''
PREFIX license: <dokk:license:>
PREFIX mimi_eunice: <dokk:mimi_and_eunice:>
DESCRIBE $iri ?license
WHERE {
$iri mimi_eunice:license ?license
}
''').substitute(iri=iri),
{
'@context': {
'license': 'dokk:license:',
'mimi_eunice': 'dokk:mimi_and_eunice:',
'mimi_eunice:tag': { '@container': '@set' },
'mimi_eunice:transcript': { '@container': '@set' }
},
'mimi_eunice:license': {},
'mimi_eunice:transcript': {
'mimi_eunice:order': {}
}
})
return template('templates/mimi_and_eunice/strip.tpl', data=data['@graph'][0])
@bottle.get('/articles', name='articles')
def articles():
"""
"""
pages = [ page.stem for page in sorted(pathlib.Path('./pages').glob('*.html')) ]
pages.remove('.html')
return template('templates/articles.tpl', pages=pages)
# TODO Make this obsolete. Replace with controllers
@bottle.get('/', name='homepage')
@bottle.get('/<page:path>', name='page')
def article(page=''):
"""
Path for serving a "page".
"""
page += '.html'
return template(page)