Files
adminandClaude Opus 5.5 acd0406181 Anleitungen im Portal unter /hilfe und in der Suche
Das Portal zeigt die Anleitungen aus dem Repo manuals selbst an
(Klon unter /opt/tuxflotte-manuals, KUNDENPLATTFORM_MANUALS_DIR) statt
auf git.tuxflotte.de zu verlinken; geänderte Dateien werden ohne
Neustart neu eingelesen. Verweise zwischen den Anleitungen zeigen auf
/hilfe, Anker wie bei Gitea, kein Roh-HTML.

Die Suche hat einen neuen Bereich "Anleitungen" (Abschnitte nach
Überschriften, Neuigkeiten/Roadmap nachrangig), Funktionstreffer der
Kunden verlinken die passende Anleitung. Kopfzeile: Hilfe-Knopf neben
der Lupe; der Secure-Boot-Hinweis verlinkt jetzt /hilfe.

Neue Abhängigkeit Markdown 3.11.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:19:11 +01:00

155 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Anleitungen (Repo manuals) im Portal unter /hilfe und in der Suche.
Nutzer-Wunsch 30.10.2026: Die Suche soll auch die Anleitungen finden, und
das Portal soll sie selbst zeigen statt auf git.tuxflotte.de zu verlinken
(Gitea geht nach ADR-0046 Schritt 4 nach außen zu). Auf anode liegt ein
Klon von manuals unter /opt/tuxflotte-manuals, aktualisiert per
`tuxflotte-deploy manuals`. Gelesen wird bei Bedarf: ändert sich eine
Datei (mtime), wird neu eingelesen, ein Neustart ist nicht nötig.
"""
import html
import os
import posixpath
import re
import threading
import xml.etree.ElementTree as etree
from dataclasses import dataclass, field
from pathlib import Path
import markdown
from markdown.extensions.toc import slugify_unicode
from markdown.treeprocessors import Treeprocessor
MANUALS_DIR = Path(os.environ.get("KUNDENPLATTFORM_MANUALS_DIR", "/opt/tuxflotte-manuals"))
# Welche Dateien das Portal zeigt; README.md ist die Übersicht unter /hilfe.
ORDNER = ("kunden-admin", "nutzer")
EINZELDATEIEN = ("README.md", "neuigkeiten.md", "roadmap.md")
# Änderungsprotokoll und Planung: in der Suche hinter den eigentlichen
# Anleitungen, sonst verdrängen sie bei fast jedem Begriff die Treffer.
NACHRANGIG = ("neuigkeiten", "roadmap")
@dataclass
class Abschnitt:
titel: str
anker: str
text: str
@dataclass
class Anleitung:
pfad: str # ohne .md, z. B. "kunden-admin/veyon"; README → ""
titel: str
html: str
abschnitte: list[Abschnitt] = field(default_factory=list)
def _ziel(von: str, href: str) -> str | None:
"""Relativen Verweis zwischen den Anleitungen auf /hilfe umschreiben.
von: Pfad der Datei ohne .md. None = Verweis unverändert lassen."""
if re.match(r"^[a-z][a-z0-9+.-]*:", href, re.I) or href.startswith(("/", "#")):
return None
ziel, _, anker = href.partition("#")
ziel = posixpath.normpath(posixpath.join(posixpath.dirname(von), ziel))
if ziel.endswith(".md"):
ziel = ziel[:-3]
if ziel in (".", "README") or ziel.rstrip("/") in ORDNER:
# Ordner-Verweise der README (kunden-admin/) führen auf die Übersicht.
ziel = ""
url = "/hilfe/" + ziel if ziel else "/hilfe"
return url + ("#" + anker if anker else "")
class _VerweiseUmschreiben(Treeprocessor):
def __init__(self, md, von: str):
super().__init__(md)
self.von = von
def run(self, root: etree.Element):
for a in root.iter("a"):
href = a.get("href") or ""
neu = _ziel(self.von, href)
if neu is not None:
a.set("href", neu)
elif href.startswith(("http://", "https://")):
a.set("target", "_blank")
a.set("rel", "noopener")
def _umwandeln(quelle: str, von: str) -> str:
md = markdown.Markdown(
extensions=["tables", "sane_lists", "toc"],
# Wie Gitea: Kleinbuchstaben, Umlaute bleiben – die Anker in den
# Anleitungen (#änderungen-schneller-…) sind so geschrieben.
extension_configs={"toc": {"slugify": slugify_unicode}},
)
# Kein Roh-HTML durchreichen, die Anleitungen brauchen keins.
md.preprocessors.deregister("html_block")
md.inlinePatterns.deregister("html")
md.treeprocessors.register(_VerweiseUmschreiben(md, von), "verweise", 1)
return md.convert(quelle)
_UEBERSCHRIFT = re.compile(r'<h([1-6]) id="([^"]*)">(.*?)</h\1>', re.S)
def _klartext(fragment: str) -> str:
text = re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
# Tags werden zu Leerzeichen, auch vor Satzzeichen ("Merkmale ).").
return re.sub(r" ([.,;:!?)“])", r"\1", text)
def _abschnitte(seite_html: str) -> tuple[str, list[Abschnitt]]:
"""Seite an den Überschriften teilen. Rückgabe: Titel (erste
Überschrift) und Abschnitte; der Text vor der zweiten Überschrift
gehört zum Titel-Abschnitt."""
teile = _UEBERSCHRIFT.split(seite_html)
# teile: [vorlauf, ebene, id, titel, text, ebene, id, titel, text, ...]
abschnitte = [
Abschnitt(titel=_klartext(teile[i + 2]), anker=teile[i + 1], text=_klartext(teile[i + 3]))
for i in range(1, len(teile) - 1, 4)
]
titel = abschnitte[0].titel if abschnitte else ""
return titel, abschnitte
def _dateien() -> list[Path]:
dateien = [MANUALS_DIR / name for name in EINZELDATEIEN]
for ordner in ORDNER:
dateien += sorted((MANUALS_DIR / ordner).glob("*.md"))
return [d for d in dateien if d.is_file()]
_sperre = threading.Lock()
_stand: tuple = ()
_anleitungen: dict[str, Anleitung] = {}
def alle() -> dict[str, Anleitung]:
"""Alle Anleitungen nach Pfad, bei geänderten Dateien neu eingelesen.
Fehlt der Klon, ist das Ergebnis leer (Hilfe zeigt dann einen Hinweis)."""
global _stand, _anleitungen
dateien = _dateien()
stand = tuple((str(d), d.stat().st_mtime_ns) for d in dateien)
with _sperre:
if stand != _stand:
neu = {}
for datei in dateien:
pfad = datei.relative_to(MANUALS_DIR).with_suffix("").as_posix()
pfad = "" if pfad == "README" else pfad
seite_html = _umwandeln(datei.read_text(encoding="utf-8"), pfad or "README")
titel, abschnitte = _abschnitte(seite_html)
neu[pfad] = Anleitung(pfad=pfad, titel=titel or pfad, html=seite_html, abschnitte=abschnitte)
_anleitungen, _stand = neu, stand
return _anleitungen
def nachrangig(pfad: str) -> bool:
return pfad in NACHRANGIG