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>
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""
|
||
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
|