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"{tag}>")
+
+ 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"
📝 {{ pending_review_count }} Artikel warten auf deine redaktionelle Freigabe —
- jetzt prüfen.
+ jetzt prüfen.
Erst nach der Freigabe gehen sie nach WordPress.
+ {{ articles|length }}
+ {{ "Artikel wartet" if articles|length == 1 else "Artikel warten" }} auf deine Freigabe
+
+
+ Lies den Text, dann entscheide: freigeben, neu schreiben oder verwerfen.
+ Erst die Freigabe legt den WordPress-Beitrag an und bucht den
+ Veröffentlichungs-Slot. Prüfer und Uhrzeit werden dabei automatisch
+ festgehalten (Art. 50 Abs. 4 KI-VO).
+
+ {% if articles|length > 1 %}
+
+ {% for a in articles %}
+