315 lines
9.3 KiB
Python
315 lines
9.3 KiB
Python
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
|
# this repository contains the full copyright notices and license terms.
|
|
import base64
|
|
import html
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import unquote
|
|
|
|
from trytond.model import ModelSingleton, ModelSQL, ModelView, fields
|
|
from trytond.transaction import Transaction
|
|
|
|
|
|
DOCUMENTATION_ROOT = Path(__file__).with_name('process_documentation')
|
|
MARKDOWN_LINK = re.compile(r'(!?)\[([^\]]*)\]\(([^)]+)\)')
|
|
HTML_IMAGE_SOURCE = re.compile(
|
|
r'(<img\b[^>]*\bsrc=)(["\'])([^"\']+)(\2)', re.IGNORECASE)
|
|
|
|
|
|
class ProcessDocumentation(ModelSingleton, ModelSQL, ModelView):
|
|
"Process Documentation"
|
|
__name__ = 'purchase_trade.process.documentation'
|
|
|
|
content = fields.Function(fields.Text('Content'), 'get_content')
|
|
|
|
@staticmethod
|
|
def default_content():
|
|
return _html_content()
|
|
|
|
def get_content(self, name):
|
|
return _html_content()
|
|
|
|
|
|
def _html_content():
|
|
html_file = Transaction().context.get('process_documentation_html')
|
|
if html_file:
|
|
return _html_file_content(html_file)
|
|
|
|
document = HTML_TEMPLATE % {
|
|
'content': _documentation_content(),
|
|
}
|
|
return IFRAME_TEMPLATE % {
|
|
'document': html.escape(document, quote=True),
|
|
}
|
|
|
|
|
|
def _html_file_content(filename):
|
|
path = (DOCUMENTATION_ROOT / Path(*filename.split('/'))).resolve()
|
|
root = DOCUMENTATION_ROOT.resolve()
|
|
common_path = os.path.commonpath([str(root), str(path)])
|
|
if path != root and common_path == str(root):
|
|
if path.exists() and path.is_file() and path.suffix.lower() == '.html':
|
|
document = path.read_text(encoding='utf-8')
|
|
document = _inline_html_assets(document, _relative_name(path))
|
|
return IFRAME_TEMPLATE % {
|
|
'document': html.escape(document, quote=True),
|
|
}
|
|
return _html_error('Documentation page not found: %s' % filename)
|
|
|
|
|
|
def _inline_html_assets(document, current_file):
|
|
def replace(match):
|
|
prefix, quote, source, suffix = match.groups()
|
|
resolved = _resolve_link(current_file, source)
|
|
if resolved.startswith(('http://', 'https://', 'data:', 'mailto:')):
|
|
return match.group(0)
|
|
|
|
data_uri = _asset_data_uri(resolved)
|
|
if not data_uri:
|
|
return match.group(0)
|
|
return '%s%s%s%s' % (prefix, quote, html.escape(data_uri, quote=True),
|
|
suffix)
|
|
|
|
return HTML_IMAGE_SOURCE.sub(replace, document)
|
|
|
|
|
|
def _html_error(message):
|
|
document = HTML_TEMPLATE % {
|
|
'content': '<p>%s</p>' % html.escape(message),
|
|
}
|
|
return IFRAME_TEMPLATE % {
|
|
'document': html.escape(document, quote=True),
|
|
}
|
|
|
|
|
|
def _documentation_content():
|
|
sections = []
|
|
index = DOCUMENTATION_ROOT / 'README.md'
|
|
if index.exists():
|
|
sections.append(_render_page(index))
|
|
|
|
for path in sorted(DOCUMENTATION_ROOT.rglob('*.md')):
|
|
if path == index:
|
|
continue
|
|
sections.append(_render_page(path))
|
|
return '\n'.join(sections)
|
|
|
|
|
|
def _render_page(path):
|
|
relative_name = _relative_name(path)
|
|
markdown = path.read_text(encoding='utf-8')
|
|
return '<section id="%s">%s</section>' % (
|
|
_anchor(relative_name), _markdown_to_html(markdown, relative_name))
|
|
|
|
|
|
def _relative_name(path):
|
|
return path.relative_to(DOCUMENTATION_ROOT.resolve()).as_posix()
|
|
|
|
|
|
def _anchor(filename):
|
|
return 'doc-' + re.sub(r'[^a-zA-Z0-9_-]+', '-', filename)
|
|
|
|
|
|
def _resolve_link(current_file, target):
|
|
if target.startswith(('#', 'http://', 'https://', 'mailto:')):
|
|
return target
|
|
target = unquote(target).replace('\\', '/')
|
|
current_dir = posixpath.dirname(current_file)
|
|
return posixpath.normpath(posixpath.join(current_dir, target)).lstrip('/')
|
|
|
|
|
|
def _asset_data_uri(filename):
|
|
path = (DOCUMENTATION_ROOT / Path(*filename.split('/'))).resolve()
|
|
root = DOCUMENTATION_ROOT.resolve()
|
|
common_path = os.path.commonpath([str(root), str(path)])
|
|
if path != root and common_path == str(root):
|
|
if path.exists() and path.is_file():
|
|
mimetype = mimetypes.guess_type(str(path))[0]
|
|
mimetype = mimetype or 'application/octet-stream'
|
|
data = base64.b64encode(path.read_bytes()).decode('ascii')
|
|
return 'data:%s;base64,%s' % (mimetype, data)
|
|
|
|
|
|
def _inline(markdown, current_file):
|
|
parts = []
|
|
last = 0
|
|
for match in MARKDOWN_LINK.finditer(markdown):
|
|
parts.append(html.escape(markdown[last:match.start()]))
|
|
is_image, label, target = match.groups()
|
|
resolved = _resolve_link(current_file, target)
|
|
label = html.escape(label)
|
|
|
|
if is_image:
|
|
source = _asset_data_uri(resolved)
|
|
if source:
|
|
parts.append('<img src="%s" alt="%s"/>' % (
|
|
html.escape(source, quote=True), label))
|
|
else:
|
|
parts.append(label)
|
|
elif resolved.endswith('.md'):
|
|
parts.append(_internal_link(_anchor(resolved), label))
|
|
elif resolved.startswith('#'):
|
|
parts.append(_internal_link(resolved.lstrip('#'), label))
|
|
elif resolved.startswith(('http://', 'https://', 'mailto:')):
|
|
parts.append('<a href="%s">%s</a>' % (
|
|
html.escape(resolved, quote=True), label))
|
|
else:
|
|
parts.append(html.escape(match.group(0)))
|
|
last = match.end()
|
|
parts.append(html.escape(markdown[last:]))
|
|
return ''.join(parts)
|
|
|
|
|
|
def _markdown_to_html(markdown, current_file):
|
|
body = []
|
|
table = []
|
|
in_list = False
|
|
|
|
def close_list():
|
|
nonlocal in_list
|
|
if in_list:
|
|
body.append('</ul>')
|
|
in_list = False
|
|
|
|
def flush_table():
|
|
nonlocal table
|
|
if table:
|
|
close_list()
|
|
body.append(_render_table(table, current_file))
|
|
table = []
|
|
|
|
for line in markdown.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith('|') and stripped.endswith('|'):
|
|
table.append(stripped)
|
|
continue
|
|
|
|
flush_table()
|
|
if not stripped:
|
|
close_list()
|
|
continue
|
|
|
|
if stripped.startswith('#'):
|
|
close_list()
|
|
level = len(stripped) - len(stripped.lstrip('#'))
|
|
level = min(level, 6)
|
|
title = stripped[level:].strip()
|
|
body.append('<h%d>%s</h%d>' % (
|
|
level, _inline(title, current_file), level))
|
|
elif stripped.startswith('- '):
|
|
if not in_list:
|
|
body.append('<ul>')
|
|
in_list = True
|
|
body.append('<li>%s</li>' % _inline(
|
|
stripped[2:].strip(), current_file))
|
|
else:
|
|
close_list()
|
|
body.append('<p>%s</p>' % _inline(stripped, current_file))
|
|
|
|
flush_table()
|
|
close_list()
|
|
|
|
if current_file != 'README.md':
|
|
body.insert(
|
|
0, '<p>%s</p>' % _internal_link('doc-README-md', 'Back to index'))
|
|
return '\n'.join(body)
|
|
|
|
|
|
def _internal_link(anchor, label):
|
|
anchor = html.escape(anchor, quote=True)
|
|
return '<a href="#%s" data-doc-target="%s">%s</a>' % (
|
|
anchor, anchor, label)
|
|
|
|
|
|
def _render_table(lines, current_file):
|
|
rows = [
|
|
[cell.strip() for cell in line.strip('|').split('|')]
|
|
for line in lines
|
|
]
|
|
header = rows[0] if rows else []
|
|
data = rows[1:]
|
|
if data and all(set(cell) <= {'-', ':'} for cell in data[0]):
|
|
data = data[1:]
|
|
|
|
output = ['<table>', '<thead><tr>']
|
|
for cell in header:
|
|
output.append('<th>%s</th>' % _inline(cell, current_file))
|
|
output.append('</tr></thead><tbody>')
|
|
for row in data:
|
|
output.append('<tr>')
|
|
for cell in row:
|
|
output.append('<td>%s</td>' % _inline(cell, current_file))
|
|
output.append('</tr>')
|
|
output.append('</tbody></table>')
|
|
return ''.join(output)
|
|
|
|
|
|
IFRAME_TEMPLATE = '''<iframe srcdoc="%(document)s"
|
|
style="border: 0; height: calc(100vh - 160px); min-height: 760px; width: 100%%;"
|
|
title="Tradon Processes"></iframe>
|
|
'''
|
|
|
|
|
|
HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<style>
|
|
body {
|
|
color: #1f2933;
|
|
font-family: Arial, sans-serif;
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
padding: 24px;
|
|
}
|
|
a {
|
|
color: #0b63ce;
|
|
text-decoration: none;
|
|
}
|
|
a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
table {
|
|
border-collapse: collapse;
|
|
margin: 16px 0;
|
|
width: 100%%;
|
|
}
|
|
th,
|
|
td {
|
|
border: 1px solid #d7dde5;
|
|
padding: 8px 10px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
th {
|
|
background: #f3f6f9;
|
|
}
|
|
img {
|
|
height: auto;
|
|
max-width: 100%%;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>%(content)s</main>
|
|
<script>
|
|
document.addEventListener('click', function(event) {
|
|
var link = event.target.closest('a[data-doc-target]');
|
|
if (!link) {
|
|
return;
|
|
}
|
|
var target = document.getElementById(link.dataset.docTarget);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
target.scrollIntoView({block: 'start'});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|