feat: integrate user manual into help section
This commit is contained in:
@@ -3,13 +3,26 @@ Naue Roll Calculator - Beta
|
||||
Flask app with HTTP Basic Authentication
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from markdown_it import MarkdownIt
|
||||
from markupsafe import Markup
|
||||
from werkzeug.security import check_password_hash
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth()
|
||||
@@ -43,6 +56,9 @@ BETA_USERS = {
|
||||
# Logging für Auditing
|
||||
LOG_FILE = "access_log.json"
|
||||
BUILD_INFO_FILE = "build_info.json"
|
||||
DOCS_DIR = os.path.join(app.root_path, "docs")
|
||||
DEFAULT_DOC_LANGUAGE = "de"
|
||||
USER_MANUAL_FILENAME = "user_manual.md"
|
||||
UNKNOWN_BUILD_INFO = {
|
||||
"version": "unknown",
|
||||
"branch": "unknown",
|
||||
@@ -120,6 +136,83 @@ def log_access(username, endpoint, method, status=200):
|
||||
# ROUTES
|
||||
# ============================================================================
|
||||
|
||||
def slugify_heading(text):
|
||||
"""Create stable, URL-friendly heading ids for rendered Markdown."""
|
||||
normalized = unicodedata.normalize("NFKD", text)
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def build_manual_renderer():
|
||||
"""Build a Markdown renderer with raw HTML disabled."""
|
||||
renderer = MarkdownIt("commonmark", {"html": False})
|
||||
renderer.enable("table")
|
||||
return renderer
|
||||
|
||||
|
||||
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
|
||||
"""Return the Markdown path for a language-specific manual."""
|
||||
return os.path.join(DOCS_DIR, language, USER_MANUAL_FILENAME)
|
||||
|
||||
|
||||
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
|
||||
"""Return the screenshot directory for a language-specific manual."""
|
||||
return os.path.join(DOCS_DIR, language, "screenshots")
|
||||
|
||||
|
||||
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
|
||||
"""Route relative manual screenshot links through the protected docs route."""
|
||||
screenshot_url = url_for("manual_screenshot", filename="")
|
||||
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
|
||||
|
||||
|
||||
def add_heading_ids_and_toc(tokens):
|
||||
"""Attach ids to headings and create a compact table of contents."""
|
||||
toc = []
|
||||
used_slugs = {}
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type != "heading_open":
|
||||
continue
|
||||
|
||||
level = int(token.tag[1])
|
||||
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
|
||||
title = (
|
||||
inline_token.content
|
||||
if inline_token and inline_token.type == "inline"
|
||||
else ""
|
||||
)
|
||||
base_slug = slugify_heading(title)
|
||||
count = used_slugs.get(base_slug, 0)
|
||||
used_slugs[base_slug] = count + 1
|
||||
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
|
||||
token.attrSet("id", slug)
|
||||
|
||||
if level <= 2:
|
||||
toc.append({
|
||||
"level": level,
|
||||
"title": title,
|
||||
"id": slug
|
||||
})
|
||||
|
||||
return toc
|
||||
|
||||
|
||||
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
|
||||
"""Read and render the Markdown user manual into HTML and a generated TOC."""
|
||||
renderer = build_manual_renderer()
|
||||
manual_path = get_manual_path(language)
|
||||
|
||||
with open(manual_path, "r", encoding="utf-8") as manual_file:
|
||||
markdown_text = manual_file.read()
|
||||
|
||||
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
|
||||
tokens = renderer.parse(markdown_text)
|
||||
toc = add_heading_ids_and_toc(tokens)
|
||||
html = renderer.renderer.render(tokens, renderer.options, {})
|
||||
return Markup(html), toc
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
@auth.login_required
|
||||
def index():
|
||||
@@ -127,6 +220,53 @@ def index():
|
||||
log_access(auth.current_user(), "/", "GET")
|
||||
return render_template("roll_calculator.html")
|
||||
|
||||
@app.route("/help", methods=["GET"])
|
||||
@auth.login_required
|
||||
def help_index():
|
||||
"""Help landing page - requires authentication."""
|
||||
log_access(auth.current_user(), "/help", "GET")
|
||||
return render_template("help_index.html")
|
||||
|
||||
@app.route("/help/user-manual", methods=["GET"])
|
||||
@auth.login_required
|
||||
def user_manual():
|
||||
"""Render the Markdown user manual inside the RollCalc layout."""
|
||||
log_access(auth.current_user(), "/help/user-manual", "GET")
|
||||
try:
|
||||
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
|
||||
return render_template(
|
||||
"user_manual.html",
|
||||
manual_html=manual_html,
|
||||
toc=toc,
|
||||
render_error=None
|
||||
)
|
||||
except Exception:
|
||||
app.logger.exception("Could not render user manual")
|
||||
return render_template(
|
||||
"user_manual.html",
|
||||
manual_html=None,
|
||||
toc=[],
|
||||
render_error=(
|
||||
"The user manual could not be loaded. "
|
||||
"Please contact the RollCalc maintainer if the problem persists."
|
||||
)
|
||||
), 500
|
||||
|
||||
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
|
||||
@auth.login_required
|
||||
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
|
||||
"""Serve user-manual screenshots from the selected language directory."""
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
f"/help/user-manual/screenshots/{filename}",
|
||||
"GET"
|
||||
)
|
||||
try:
|
||||
return send_from_directory(get_manual_screenshot_dir(language), filename)
|
||||
except Exception:
|
||||
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
|
||||
abort(404)
|
||||
|
||||
@app.route("/static/<path:filename>", methods=["GET"])
|
||||
@auth.login_required
|
||||
def serve_static(filename):
|
||||
|
||||
Reference in New Issue
Block a user