# -*- coding: utf-8 -*-
#
# This file is part of INGInious. See the LICENSE and the COPYRIGHTS files for
# more information about the licensing of this file.
""" Tools to parse text """
import html
import re
import gettext
import flask
import tidylib
from datetime import datetime
from urllib.parse import urlparse
from docutils import core, nodes
from docutils.parsers.rst import directives, Directive
from docutils.parsers.rst.directives.admonitions import BaseAdmonition
from docutils.parsers.rst.directives.body import CodeBlock
from docutils.statemachine import StringList
from docutils.writers import html4css1
from inginious.frontend.accessible_time import parse_date
def _get_inginious_translation():
# If we are on a webpage, or even anywhere in the app, this should be defined
if flask.has_app_context():
return flask.current_app.l10n_manager.get_translation_obj()
else:
return gettext.NullTranslations()
[docs]class EmptiableCodeBlock(CodeBlock):
[docs] def run(self):
if not self.content:
translation = _get_inginious_translation()
self.content = [translation.gettext("[no content]")]
return super(EmptiableCodeBlock, self).run()
[docs]class CustomBaseAdmonition(BaseAdmonition):
""" A custom admonition that can have a title """
option_spec = {'class': directives.class_option,
'name': directives.unchanged,
'title': directives.unchanged}
[docs]class CustomAdmonition(CustomBaseAdmonition):
""" A custom admonition with a specific class if needed """
required_arguments = 1
node_class = nodes.admonition
[docs]class HiddenUntilDirective(Directive, object):
required_arguments = 1
has_content = True
optional_arguments = 1
option_spec = {}
[docs] def run(self):
self.assert_has_content()
hidden_until = " ".join(self.arguments) #join date and optional time argument
try:
hidden_until = parse_date(hidden_until)
except:
raise self.error('Unknown date format in the "%s" directive; '
'%s' % (self.name, hidden_until))
force_show = self.state.document.settings.force_show_hidden_until
translation = _get_inginious_translation()
after_deadline = hidden_until <= datetime.now()
if after_deadline or force_show:
output = []
# Add a warning for teachers/tutors/...
if not after_deadline and force_show:
node = nodes.caution()
self.add_name(node)
text = translation.gettext("The feedback below will be hidden to the students until {}.").format(
hidden_until.strftime("%d/%m/%Y %H:%M:%S"))
self.state.nested_parse(StringList(text.split("\n")), 0, node)
output.append(node)
text = '\n'.join(self.content)
node = nodes.compound(text)
self.add_name(node)
self.state.nested_parse(self.content, self.content_offset, node)
output.append(node)
return output
else:
node = nodes.caution()
self.add_name(node)
text = translation.gettext(
"A part of this feedback is hidden until {}. Please come back later and reload the submission to see the full feedback.").format(
hidden_until.strftime("%d/%m/%Y %H:%M:%S"))
self.state.nested_parse(StringList(text.split("\n")), 0, node)
return [node]
class _CustomHTMLWriter(html4css1.Writer, object):
""" A custom HTML writer that fixes some defaults of docutils... """
def __init__(self):
html4css1.Writer.__init__(self)
self.translator_class = self._CustomHTMLTranslator
class _CustomHTMLTranslator(html4css1.HTMLTranslator, object): # pylint: disable=abstract-method
""" A custom HTML translator """
def visit_container(self, node):
""" Custom version of visit_container that do not put 'container' in div class"""
self.body.append(self.starttag(node, 'div'))
def visit_literal(self, node):
""" A custom version of visit_literal that uses the balise <code> instead of <tt>. """
# special case: "code" role
classes = node.get('classes', [])
if 'code' in classes:
# filter 'code' from class arguments
node['classes'] = [cls for cls in classes if cls != 'code']
self.body.append(self.starttag(node, 'code', ''))
return
self.body.append(
self.starttag(node, 'code', '', CLASS='docutils literal'))
text = node.astext()
for token in self.words_and_spaces.findall(text):
if token.strip():
# Protect text like "--an-option" and the regular expression
# ``[+]?(\d+(\.\d*)?|\.\d+)`` from bad line wrapping
if self.in_word_wrap_point.search(token):
self.body.append('<span class="pre">%s</span>'
% self.encode(token))
else:
self.body.append(self.encode(token))
elif token in ('\n', ' '):
# Allow breaks at whitespace:
self.body.append(token)
else:
# Protect runs of multiple spaces; the last space can wrap:
self.body.append(' ' * (len(token) - 1) + ' ')
self.body.append('</code>')
# Content already processed:
raise nodes.SkipNode
def starttag(self, node, tagname, suffix='\n', empty=False, **attributes):
""" Ensures all links to outside this instance of INGInious have target='_blank' """
if tagname == 'a' and "href" in attributes and not attributes["href"].startswith('#'):
attributes["target"] = "_blank"
# Rewrite paths if we are in LTI mode
# TODO: this should be an argument passed through all the functions
if re.match(r"^(/@[a-f0-9A-F_]*@)", flask.request.path if flask.has_app_context() else ""):
if tagname == 'a' and 'href' in attributes:
attributes['href'] = self.rewrite_lti_url(attributes['href'])
elif tagname == 'img' and 'src' in attributes:
attributes['src'] = self.rewrite_lti_url(attributes['src'])
return html4css1.HTMLTranslator.starttag(self, node, tagname, suffix, empty, **attributes)
@staticmethod
def rewrite_lti_url(url):
if urlparse(url).netloc: # If URL is absolute, don't do anything
return url
return 'asset/' + url
def visit_table(self, node):
""" Remove needless borders """
self.context.append(self.compact_p)
self.compact_p = True
classes = ['docutils', 'table', 'table-bordered', self.settings.table_style]
if 'align' in node:
classes.append('align-%s' % node['align'])
self.body.append(
self.starttag(node, 'table', CLASS=' '.join(classes)))
def visit_tbody(self, node):
""" Remove needless valign"""
self.body.append(self.starttag(node, 'tbody'))
def visit_thead(self, node):
""" Remove needless valign, add bootstrap class """
self.body.append(self.starttag(node, 'thead', CLASS='thead-light'))
def visit_admonition(self, node):
""" Support for bootstrap alert/cards """
node['classes'].insert(0, 'admonition')
converter = {
'danger': 'danger',
'attention': 'warning',
'caution': 'warning',
'error': 'danger',
'hint': 'info',
'important': 'warning',
'note': 'default',
'tip': 'info',
'warning': 'warning',
'success': 'success',
'info': 'info',
'primary': 'primary',
'secondary': 'secondary',
'light': 'light',
'dark': 'dark'
}
cls = [x if not x.startswith('admonition-') else x[11:] for x in node['classes']]
cls = [converter.get(x) for x in cls if converter.get(x) is not None]
if len(cls) == 0:
cls = 'info'
else:
cls = cls[0]
if "title" in node and node['title'] != "":
self.body.append(self.starttag(node, 'div', CLASS='card mb-3 border-' + cls))
card_color = "bg-" + cls
if cls not in ['default', 'light', 'secondary']:
card_color += ' text-white'
self.body.append(self.starttag(node, 'div', CLASS='card-header ' + card_color))
self.body.append(self.encode(node['title']))
self.body.append('</div>\n')
self.body.append(self.starttag(node, 'div', CLASS='card-body'))
else:
if cls == "default":
cls = 'light'
self.body.append(self.starttag(node, 'div', CLASS='alert alert-' + cls))
self.set_first_last(node)
# drop unneeded title
node.children = node.children[1:]
def depart_admonition(self, node):
if "title" in node and node['title'] != "":
self.body.append('</div>\n')
self.body.append('</div>\n')
[docs]class ParsableText(object):
"""Allow to parse a string with different parsers"""
def __init__(self, content, mode="rst", show_everything=False, translation=gettext.NullTranslations()):
"""
content The string to be parsed.
mode The parser to be used. Currently, only rst(reStructuredText) and HTML are supported.
show_everything Shows things that are normally hidden, such as the hidden-util directive.
"""
mode = mode.lower()
if mode not in ["rst", "html"]:
raise Exception("Unknown text parser: " + mode)
self._content = content
self._parsed = None
self._translation = translation
self._mode = mode
self._show_everything = show_everything
[docs] def original_content(self):
""" Returns the original content """
return self._content
[docs] def parse(self, debug=False):
"""Returns parsed text"""
if self._parsed is None:
try:
if self._mode == "html":
self._parsed = self.html(self._content, self._show_everything, self._translation)
else:
self._parsed = self.rst(self._content, self._show_everything, self._translation, debug=debug)
except Exception as e:
if debug:
raise BaseException("Parsing failed") from e
else:
self._parsed = self._translation.gettext("<b>Parsing failed</b>: <pre>{}</pre>").format(
html.escape(self._content))
return self._parsed
def __str__(self):
"""Returns parsed text"""
return self.parse()
def __unicode__(self):
"""Returns parsed text"""
return self.parse()
[docs] @classmethod
def html(cls, string, show_everything=False,
translation=gettext.NullTranslations()): # pylint: disable=unused-argument
"""Parses HTML"""
out, _ = tidylib.tidy_fragment(string)
return out
[docs] @classmethod
def rst(cls, string, show_everything=False, translation=gettext.NullTranslations(), initial_header_level=3,
debug=False):
"""Parses reStructuredText"""
overrides = {
'initial_header_level': initial_header_level,
'doctitle_xform': False,
'syntax_highlight': 'none',
'force_show_hidden_until': show_everything,
'translation': translation,
'raw_enabled': True,
'file_insertion_enabled': False,
'math_output': 'MathJax /this/does/not/need/to/exist.js',
'line_length_limit': len(string) # Use string size to be safe.
}
if debug:
overrides['halt_level'] = 2
overrides['traceback'] = True
parts = core.publish_parts(source=string, writer=_CustomHTMLWriter(),
settings_overrides=overrides)
return parts['body_pre_docinfo'] + parts['fragment']
# override base directives
def _gen_admonition_cls(cls):
class GenAdm(CustomBaseAdmonition):
node_class = cls
return GenAdm
directives.register_directive("admonition", CustomAdmonition)
directives.register_directive("attention", _gen_admonition_cls(nodes.attention))
directives.register_directive("caution", _gen_admonition_cls(nodes.caution))
directives.register_directive("danger", _gen_admonition_cls(nodes.danger))
directives.register_directive("error", _gen_admonition_cls(nodes.error))
directives.register_directive("hint", _gen_admonition_cls(nodes.hint))
directives.register_directive("important", _gen_admonition_cls(nodes.important))
directives.register_directive("note", _gen_admonition_cls(nodes.note))
directives.register_directive("tip", _gen_admonition_cls(nodes.tip))
directives.register_directive("warning", _gen_admonition_cls(nodes.warning))
directives.register_directive("hidden-until", HiddenUntilDirective)
directives.register_directive("code-block", EmptiableCodeBlock)