shinkan-jinkendo/backend/exercise_rich_text.py

223 lines
7.8 KiB
Python
Raw Normal View History

"""
Übungs-Fließtext: Inline-Verweise auf exercise_media.id (MEDIA_ASSETS_AND_ARCHIVE_SPEC §11).
- Kurzsyntax beim Speichern → kanonisches <span data-shinkan-exercise-media="…">.
- Verweise müssen für die betreffende Übung gültige exercise_media-Zeilen sein.
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
from typing import FrozenSet, Optional, Set
from html import escape as html_escape
from fastapi import HTTPException
# {{exerciseMedia:123}} (Whitespace tolerant, case-insensitive Schlüssel)
_BRACE_PATTERN = re.compile(r"\{\{\s*exerciseMedia\s*:\s*(\d+)\s*\}\}", re.IGNORECASE)
# bereits gespeichertes Markup (einfache Anführungszeichen-varianten durch Regex abgedeckt)
_DATA_ATTR_PATTERN = re.compile(r'data-shinkan-exercise-media\s*=\s*["\']?(\d+)["\']?', re.IGNORECASE)
RICH_HTML_EXERCISE_FIELDS: FrozenSet[str] = frozenset(
{"summary", "goal", "execution", "preparation", "trainer_notes"}
)
def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]:
"""Wandelt {{exerciseMedia:id}} in kanonisches span mit data-shinkan-exercise-media."""
if html is None:
return None
if not isinstance(html, str):
html = str(html)
stripped = html.strip()
if not stripped:
return html
def _repl(match: re.Match) -> str:
mid = int(match.group(1))
return (
f'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
f'class="shinkan-inline-media"></span>'
)
return sanitize_exercise_rich_html(_BRACE_PATTERN.sub(_repl, html))
_ALLOWED_RICH_TAGS = frozenset(
{"p", "div", "br", "ul", "ol", "li", "h3", "b", "strong", "i", "em", "u", "span", "a"}
)
_DROP_WITH_CHILDREN = frozenset(
{"script", "iframe", "object", "embed", "style", "link", "meta", "svg", "math"}
)
_VOID_TAGS = frozenset({"br"})
_MEDIA_SIZES = frozenset({"small", "medium", "full"})
_CAPTION_MAX = 120
def _clean_caption(raw: str) -> str:
text = re.sub(r"[\u0000-\u001f\u007f]", " ", raw or "")
text = re.sub(r"[\"'`<>]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:_CAPTION_MAX]
def _safe_rich_attrs(tag: str, attrs: list[tuple[str, Optional[str]]]) -> list[tuple[str, str]]:
values = {}
for name, value in attrs:
if not name:
continue
values[name.lower()] = "" if value is None else str(value)
if tag == "a":
href = values.get("href", "").strip()
if href.lower().startswith(("http://", "https://")) and not re.search(r"[\u0000-\u001f\u007f]", href):
return [("href", href)]
return []
if tag != "span":
return []
media_id = values.get("data-shinkan-exercise-media", "").strip()
if not re.fullmatch(r"\d+", media_id):
return []
out = [("data-shinkan-exercise-media", media_id)]
size = values.get("data-shinkan-exercise-media-size", "").strip().lower()
if size in _MEDIA_SIZES:
out.append(("data-shinkan-exercise-media-size", size))
caption = _clean_caption(values.get("data-shinkan-exercise-media-caption", ""))
if caption:
out.append(("data-shinkan-exercise-media-caption", caption))
classes = [c for c in values.get("class", "").split() if c == "shinkan-inline-media"]
if classes:
out.append(("class", "shinkan-inline-media"))
return out
class _RichHtmlSanitizer(HTMLParser):
"""Baut erlaubtes Übungs-HTML neu auf. Unbekannte Tags fallen weg, ihr Text bleibt."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self._drop_depth = 0
self._stack: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
self._start(tag.lower(), attrs, void=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
self._start(tag.lower(), attrs, void=True)
def _start(self, tag: str, attrs: list[tuple[str, Optional[str]]], *, void: bool) -> None:
if self._drop_depth:
if not void and tag not in _VOID_TAGS:
self._drop_depth += 1
return
if tag in _DROP_WITH_CHILDREN:
if not void:
self._drop_depth = 1
return
if tag not in _ALLOWED_RICH_TAGS:
return
safe = _safe_rich_attrs(tag, attrs)
rendered = tag
if safe:
rendered += "".join(f' {name}="{html_escape(value, quote=True)}"' for name, value in safe)
if void or tag in _VOID_TAGS:
self.parts.append(f"<{rendered}>")
return
self._stack.append(tag)
self.parts.append(f"<{rendered}>")
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if self._drop_depth:
if tag in _DROP_WITH_CHILDREN or self._drop_depth > 1:
self._drop_depth = max(0, self._drop_depth - 1)
return
if self._stack and self._stack[-1] == tag:
self._stack.pop()
self.parts.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
if self._drop_depth or not data:
return
self.parts.append(html_escape(data))
def handle_entityref(self, name: str) -> None:
self.handle_data(f"&{name};")
def handle_charref(self, name: str) -> None:
self.handle_data(f"&#{name};")
def sanitize_exercise_rich_html(html: Optional[str]) -> Optional[str]:
"""Allowlist für gespeichertes Übungs-HTML. Skripte, Handler und fremde Attribute fallen weg."""
if html is None:
return None
if not isinstance(html, str):
html = str(html)
if not html.strip():
return html
parser = _RichHtmlSanitizer()
parser.feed(html)
parser.close()
return "".join(parser.parts)
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
"""Sammelt alle referenzierten exercise_media.ids aus Kurzsyntax und kanonischem Span."""
if html is None or not isinstance(html, str):
return set()
if not html.strip():
return set()
ids: Set[int] = set()
ids.update(int(m) for m in _BRACE_PATTERN.findall(html))
ids.update(int(m) for m in _DATA_ATTR_PATTERN.findall(html))
return ids
def assert_no_inline_media_references_on_create(ids: Set[int]) -> None:
"""Neue Übung hat noch keine exercise_media-Zeilen — Platzhalter verboten."""
if not ids:
return
raise HTTPException(
status_code=400,
detail={
"code": "INLINE_EXERCISE_MEDIA_ON_CREATE",
"message": (
"Medienverweise im Fließtext sind beim ersten Anlegen der Übung nicht möglich. "
"Bitte Übung ohne Platzhalter speichern, Medien hochladen oder verknüpfen "
"und die Verweise dann bearbeiten ({{exerciseMedia:id}} oder „Medium einfügen“)."
),
"invalid_exercise_media_ids": sorted(ids),
},
)
def validate_inline_exercise_media_ids_for_exercise(cur, exercise_id: int, ids: Set[int]) -> None:
"""Prüft, dass jede genannte exercise_media.id zu dieser Übung gehört."""
if not ids:
return
sid = sorted(ids)
ph = ",".join(["%s"] * len(sid))
cur.execute(
f"SELECT id FROM exercise_media WHERE exercise_id = %s AND id IN ({ph})",
(exercise_id, *sid),
)
found = set()
for row in cur.fetchall():
rid = row["id"] if isinstance(row, dict) else row[0]
found.add(int(rid))
missing = ids - found
if missing:
raise HTTPException(
status_code=400,
detail={
"code": "INLINE_EXERCISE_MEDIA_INVALID",
"message": "Ein oder mehrere eingebettete Medien-Verweise gehören nicht zu dieser Übung.",
"invalid_exercise_media_ids": sorted(missing),
},
)