#!/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 = [ './templates', './articles' ]
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():
return bottle.static_file('robots.txt', root='./')
@bottle.get('/static/<filename:path>', name='static')
def static(filename):
"""
Path for serving static files.
"""
return bottle.static_file(filename, root='./static/')
@bottle.get('/', name='homepage')
def homepage():
"""
Homepage.
"""
return template('index.html')
@bottle.route('/library', method=['GET', 'POST'], name='library')
def library():
"""
Library index
"""
# Get a list of authors for searching
authors = query("""
PREFIX library: <dokk:vocab:library:>
SELECT DISTINCT ?name
WHERE {
[] library:author ?name
}
ORDER BY ?name
""")['results']['bindings']
# Get a list of licenses for searching
licenses = query("""
PREFIX library: <dokk:vocab:library:>
PREFIX license: <dokk:vocab:license:>
SELECT DISTINCT ?id
WHERE {
[] a library:Item;
license:licensed_under [
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:vocab:library:>
PREFIX license: <dokk:vocab:license:>
CONSTRUCT {{
?item library:title ?title;
library:author ?author ;
license:licensed_under ?license .
?license license:id ?license_id ;
license:name ?license_name .
}}
WHERE {{
?item library:title ?title ;
library:author ?author ;
license:licensed_under ?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:vocab:library:',
'license': 'dokk:vocab:license:',
'library:author': { '@container': '@set' },
'license:licensed_under': { '@container': '@set' }
},
'library:title': {}
})
return template('library/index.html', authors=authors, licenses=licenses, items=items,
filters_author=filters_author, filters_license=filters_license)
@bottle.get('/library/<item_id>', name='library_item')
def library_item(item_id):
"""
Show a single item in the library.
"""
try:
with open(f'../library_txt/{item_id}.txt', 'r') as file:
item_plaintext = file.read()
except:
item_plaintext = ''
data = query(f"""
PREFIX library: <dokk:vocab:library:>
PREFIX license: <dokk:vocab:license:>
CONSTRUCT {{
?item library:title ?title ;
library:author ?author ;
license:licensed_under ?license .
?license license:id ?license_id ;
license:name ?license_name .
}}
WHERE {{
?item
library:title ?title ;
library:author ?author ;
license:licensed_under ?license .
FILTER (?item = <dokk:{item_id}>)
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)
}}
""",
{
"@context": {
"library": "dokk:vocab:library:",
"license": "dokk:vocab:license:",
"library:author": { "@container": "@set" },
"license:licensed_under": { "@container": "@set" }
},
"library:title": {}
}
)["@graph"][0]
return template('library/item.html', item_id=item_id, plaintext=item_plaintext, data=data)
@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/index.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('license/license.html', data=data)
@bottle.get('/manpages', name='manpages')
def manpages():
"""
Manpages index.
"""
data = query(f"""
PREFIX mp: <dokk:manpages:>
SELECT ?name ?number
WHERE {{
[] a mp:Distribution ;
mp:name ?name ;
mp:number ?number
}}
ORDER BY ?name ?number
""")
return template('manpages/index.html', 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('manpages/distribution.html', 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('manpages/package.html', 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('manpages/disambiguation.html', 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('manpages/manpage.html',
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('/comics', name='comics')
def comics():
"""
Comics index.
"""
data = query("""
PREFIX comics: <dokk:vocab:comicstrip:>
SELECT DISTINCT ?id ?title
WHERE {
?id a comics:ComicStrip ;
comics:title ?title
}
ORDER BY ?id
""")
pages = data['results']['bindings']
return template('comics/index.html', pages=pages)
@bottle.get('/comics/mimi_and_eunice', name='mimi_and_eunice')
def mimi_and_eunice():
"""
Mimi&Eunice index.
"""
data = query(f"""
PREFIX comics: <dokk:vocab:comicstrip:>
SELECT ?number ?title
WHERE {{
[] comics:series <dokk:mimi_and_eunice>;
comics:number ?number;
comics:title ?title
}}
ORDER BY ?number
""")
return template('comics/mimi_and_eunice/index.html', data=data)
@bottle.get('/comics/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 blob: <dokk:vocab:blob:>
prefix license: <dokk:vocab:license:>
prefix comics: <dokk:vocab:comicstrip:>
DESCRIBE $iri ?license ?blob
WHERE {
$iri comics:license ?license;
blob:at ?blob
}
''').substitute(iri=iri),
{
'@context': {
'blob': 'dokk:vocab:blob:',
'license': 'dokk:vocab:license:',
'comics': 'dokk:vocab:comicstrip:',
'comics:tag': { '@container': '@set' },
'comics:transcript': { '@container': '@set' }
},
'comics:license': {},
'comics:transcript': {
'comics:order': {}
},
'blob:at': {}
})
return template('comics/mimi_and_eunice/strip.html', data=data['@graph'][0])
@bottle.get('/comics/xkcd', name='xkcd')
def xkcd():
"""
XKCD index.
"""
data = query(f"""
PREFIX comics: <dokk:vocab:comicstrip:>
SELECT ?id ?number ?title
WHERE {{
?id comics:series <dokk:xkcd> ;
comics:number ?number ;
comics:title ?title
}}
ORDER BY ?number
""")
data = data['results']['bindings']
return template('comics/xkcd/index.html', data=data)
@bottle.get('/comics/xkcd/<number>', name='xkcd_strip')
def xkcd_strip(number):
"""
"""
iri = '<dokk:xkcd_comic_' + number + '>'
data = query(f'''
prefix blob: <dokk:vocab:blob:>
prefix license: <dokk:vocab:license:>
prefix comics: <dokk:vocab:comicstrip:>
DESCRIBE {iri} ?license ?blob
WHERE
{{
{iri} a comics:ComicStripPanel ;
comics:license ?license;
blob:at ?blob
}}
''',
{
'@context': {
'blob': 'dokk:vocab:blob:',
'license': 'dokk:vocab:license:',
'comics': 'dokk:vocab:comicstrip:',
'comics:tag': { '@container': '@set' },
'comics:transcript': { '@container': '@set' }
},
'@type': 'comics:ComicStripPanel',
'comics:license': {},
'blob:at': {}
})
return template('comics/xkcd/strip.html', data=data['@graph'][0])
@bottle.get('/articles', name='articles')
def articles():
"""
"""
pages = [ page.stem for page in sorted(pathlib.Path('./articles').glob('*.html')) ]
return template('articles/index.html', pages=pages)
@bottle.get('/<article_title:path>', name='article')
def article(article_title=''):
"""
Path for serving an "article".
If no other controller has matched, this will look into the "articles" folder.
Keep this controller last.
"""
article_title += '.html'
return template(article_title)