diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7b422..6caebda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [1.8.1] - 2026-08-24 + +### 📋 Freigabe-Seite + - Neue Seite `/admin/freigabe`: alle Artikel im Status „Wartet auf Freigabe" auf einer Arbeitsliste + - Pro Artikel: Hauptbild, Relevanz-Score mit Begründung, Tags, Wortzahl, Alter, Link zum Originalartikel + - Der umgeschriebene Text wird **lesbar gerendert** statt als Markup — durch einen Tag-Whitelist-Filter, weil er aus Modell-Ausgabe über fremde Webseiten stammt + - Drei Aktionen direkt an jedem Artikel: ✅ Freigeben (mit Notizfeld), ✏️ Neu schreiben, ❌ Verwerfen + - Nach jeder Aktion zurück zur Liste, damit sich eine Warteschlange am Stück abarbeiten lässt + - Verlinkt aus der Navigation aller Admin-Seiten, mit Zähler im Dashboard + +### 🧹 Status-Altlasten entfernt + - Artikelliste zeigte interne Kürzel (`pending_review`, `approved`, `error`) — jetzt überall Klartext + - Status `review` (Relevanz-Warnzone 60–79) wurde als „Rewrite" angezeigt, was etwas anderes bedeutet — heißt jetzt „Niedrige Relevanz" + - Übergang `Rewrite → Freigegeben` entfernt: ein zweiter Weg an der Freigabe vorbei + - Übergang `Freigegeben → Veröffentlicht` entfernt: das setzen WordPress bzw. der WP-Sync, nicht die Hand + - API-Statusliste wird aus dem Statusmodell abgeleitet statt handgepflegt (kannte `freigabe` nicht und lehnte den Wechsel mit 422 ab) + +### 🐛 Behoben + - `list_articles` las `content_rewritten` nicht mit — die Freigabe-Seite hätte nie einen Text angezeigt + +--- + ## [1.8.0] - 2026-08-24 ### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO) diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index 8700a44..10868a3 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -1,5 +1,7 @@ from __future__ import annotations +from html import escape +from html.parser import HTMLParser import json from pathlib import Path import re @@ -50,9 +52,11 @@ from .repositories import ( ) from .workflow import ( ALLOWED_UI_TRANSITIONS, + INTERNAL_STATUSES, UI_STATUS_LABELS, UI_STATUSES, internal_to_ui_status, + ui_status_label, ui_to_internal_status, ) @@ -61,9 +65,10 @@ router = APIRouter(tags=["admin-ui"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { "new": ("rewrite", "close"), - "rewrite": ("freigabe", "publish", "close"), + "relevanz": ("rewrite", "close"), + "rewrite": ("freigabe", "close"), "freigabe": ("publish", "rewrite", "close"), - "publish": ("published", "close"), + "publish": ("close",), "published": ("rewrite", "close"), "close": ("rewrite",), "no_image": ("rewrite", "close"), @@ -180,6 +185,85 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di return entries +_PREVIEW_ALLOWED_TAGS = { + "p", "br", "h2", "h3", "h4", "ul", "ol", "li", + "strong", "b", "em", "i", "blockquote", "a", +} +_PREVIEW_DROPPED_CONTENT_TAGS = {"script", "style"} + + +class _PreviewSanitizer(HTMLParser): + """Rebuild article HTML with a small whitelist of tags. + + The rewrite text comes from a language model fed with scraped source pages, + and the Freigabe page renders it instead of showing raw markup — so it gets + filtered rather than trusted. Disallowed tags are dropped, their text kept. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + self._suppress_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in _PREVIEW_DROPPED_CONTENT_TAGS: + self._suppress_depth += 1 + return + if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS: + return + if tag == "a": + href = next((v or "" for k, v in attrs if k == "href"), "") + if href.lower().startswith(("http://", "https://")): + self.parts.append(f'') + else: + self.parts.append("") + return + self.parts.append(f"<{tag}>") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if not self._suppress_depth and tag == "br": + self.parts.append("
") + + def handle_endtag(self, tag: str) -> None: + if tag in _PREVIEW_DROPPED_CONTENT_TAGS: + self._suppress_depth = max(0, self._suppress_depth - 1) + return + if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS or tag == "br": + return + self.parts.append(f"") + + def handle_data(self, data: str) -> None: + if not self._suppress_depth: + self.parts.append(escape(data)) + + +def _sanitize_preview_html(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + parser = _PreviewSanitizer() + try: + parser.feed(text) + parser.close() + except Exception: + return f"

{escape(re.sub(r'<[^>]+>', ' ', text))}

" + cleaned = "".join(parser.parts).strip() + if not cleaned: + return "" + # Plain text without any block tag would collapse into one run. + if "

" not in cleaned and "

" not in cleaned and "