#!/usr/bin/env python3
"""IMS Management Cockpit Generator.
Liest die IMS-Markdown-Dokumente (Ordner ../), parst die Dokumentenlenkungs-Header,
konvertiert Markdown -> HTML und erzeugt EINE in sich geschlossene Datei
``IMS-Cockpit.html`` (Mermaid inline), die per Doppelklick (file://) offline nutzbar ist.
Aufruf: python build_cockpit.py
Ergebnis: IMS-Cockpit.html im selben Ordner.
Keine externen Python-Pakete nötig. Mermaid wird einmalig von einem CDN geladen und
in ../cockpit/_vendor/ zwischengespeichert, danach in die HTML eingebettet (offline).
"""
import os
import re
import json
import html
import datetime
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
IMS_DIR = os.path.dirname(HERE) # .../ims
OUT_FILE = os.path.join(HERE, "IMS-Cockpit.html")
VENDOR_DIR = os.path.join(HERE, "_vendor")
MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"
CHAPTER_LABELS = {
"00": "Handbuch",
"01_kontext": "4 - Kontext",
"02_fuehrung": "5 - Führung",
"03_planung": "6 - Planung",
"04_unterstuetzung": "7 - Unterstützung",
"05_betrieb": "8 - Betrieb / Annex A",
"06_bewertung": "9 - Bewertung",
"07_verbesserung": "10 - Verbesserung",
"mappings": "Mappings",
}
CHAPTER_ORDER = list(CHAPTER_LABELS.keys())
TODAY = datetime.date.today()
# --------------------------------------------------------------------------- #
# Markdown -> HTML (dependency-free) #
# --------------------------------------------------------------------------- #
def _inline(text):
codes = []
def _grab(m):
codes.append(m.group(1))
return "\x00C%d\x00" % (len(codes) - 1)
text = re.sub(r"`([^`]+)`", _grab, text)
text = html.escape(text, quote=False)
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: '%s' % (html.escape(m.group(2), quote=True), m.group(1)),
text,
)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"(?\1", text)
def _restore(m):
return "" + html.escape(codes[int(m.group(1))], quote=False) + ""
return re.sub(r"\x00C(\d+)\x00", _restore, text)
def _cells(row):
row = row.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [c.strip() for c in row.split("|")]
def _table(header, rows):
head = "".join("
%s' % html.escape(code, quote=False)) else: out.append('
%s' % html.escape(code, quote=False))
continue
if line.strip() == "":
i += 1
continue
if re.match(r"^\s*(---|\*\*\*|___)\s*$", line):
out.append("%s" % _inline(" ".join(buf))) continue if re.match(r"^\s*([-*+])\s+", line) or re.match(r"^\s*\d+\.\s+", line): ordered = bool(re.match(r"^\s*\d+\.\s+", line)) tag = "ol" if ordered else "ul" items = [] while i < n and (re.match(r"^\s*([-*+])\s+", lines[i]) or re.match(r"^\s*\d+\.\s+", lines[i])): item = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", lines[i]) items.append("
%s
" % _inline(" ".join(b.strip() for b in buf))) return "\n".join(out) # --------------------------------------------------------------------------- # # Parsing helpers # # --------------------------------------------------------------------------- # def parse_header(text): """Parse only the contiguous comment block at the very top of the file.""" meta = {} for line in text.split("\n"): s = line.strip() if s == "": continue m = re.match(r"^$", s) if m: meta[m.group(1)] = m.group(2).strip() continue break # first non-comment, non-blank line ends the header block return meta def doc_title(text, fallback): m = re.search(r"^#\s+(.*)$", text, re.M) return m.group(1).strip() if m else fallback def parse_date(value): if not value: return None try: return datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date() except ValueError: return None def find_zu_pruefen(text): hits = [] current = "" for line in text.split("\n"): hm = re.match(r"^#{1,6}\s+(.*)$", line) if hm: current = hm.group(1).strip() scan = re.sub(r"`[^`]*`", "", line) # Inline-Code (Marker-Erklaerungen) ignorieren if re.search(r"ZU\s*PR(?:UE|\u00dc)FEN", scan, re.I): hits.append(current or "(Dokumentanfang)") return hits def parse_massnahmen(text): """Parse the markdown action register table -> list of dict.""" rows = [] lines = text.split("\n") header_idx = None for idx, line in enumerate(lines): if "|" in line and re.search(r"\bID\b", line) and re.search(r"Titel", line, re.I): nxt = lines[idx + 1] if idx + 1 < len(lines) else "" if "-" in nxt and re.match(r"^\s*\|?[\s:|-]+\|?\s*$", nxt): header_idx = idx break if header_idx is None: return rows cols = [c.strip().lower() for c in _cells(lines[header_idx])] for line in lines[header_idx + 2:]: if "|" not in line or line.strip() == "": break vals = _cells(line) if len(vals) < len(cols): continue row = dict(zip(cols, vals)) rows.append( { "id": row.get("id", ""), "titel": row.get("titel", ""), "owner": row.get("owner", ""), "quelle": row.get("quelle", ""), "faellig": row.get("faellig", "") or row.get("f\u00e4llig", ""), "status": row.get("status", ""), } ) return rows # --------------------------------------------------------------------------- # # Collect documents # # --------------------------------------------------------------------------- # def collect_docs(): docs = [] for root, dirs, files in os.walk(IMS_DIR): dirs[:] = [d for d in dirs if d not in ("cockpit", "_vendor")] for fn in files: if not fn.endswith(".md"): continue full = os.path.join(root, fn) rel = os.path.relpath(full, IMS_DIR).replace("\\", "/") with open(full, "r", encoding="utf-8") as fh: text = fh.read() parts = rel.split("/") chapter = "00" if len(parts) == 1 else parts[0] meta = parse_header(text) next_review = parse_date(meta.get("nextReview")) overdue = bool(next_review and next_review < TODAY and meta.get("status") not in ("abgeloest", "abgelöst")) docs.append( { "id": rel, "path": rel, "chapter": chapter, "title": doc_title(text, fn), "filename": fn, "meta": meta, "html": md_to_html(text), "text": re.sub(r"\s+", " ", text).lower(), "nextReview": meta.get("nextReview", ""), "overdueReview": overdue, "zuPruefen": find_zu_pruefen(text), } ) docs.sort(key=lambda d: (CHAPTER_ORDER.index(d["chapter"]) if d["chapter"] in CHAPTER_ORDER else 99, d["filename"])) return docs def get_mermaid_js(): os.makedirs(VENDOR_DIR, exist_ok=True) cache = os.path.join(VENDOR_DIR, "mermaid.min.js") if os.path.exists(cache): with open(cache, "r", encoding="utf-8") as fh: return fh.read() try: print("Lade mermaid.min.js vom CDN ...") req = urllib.request.Request(MERMAID_URL, headers={"User-Agent": "Mozilla/5.0"}) data = urllib.request.urlopen(req, timeout=30).read().decode("utf-8") with open(cache, "w", encoding="utf-8") as fh: fh.write(data) return data except Exception as exc: # noqa: BLE001 print("WARNUNG: mermaid.min.js konnte nicht geladen werden (%s). Diagramme werden als Code angezeigt." % exc) return "" # --------------------------------------------------------------------------- # # HTML assembly # # --------------------------------------------------------------------------- # def build(): docs = collect_docs() pendenzen_reg = [] for d in docs: if d["filename"] == "massnahmen-register.md": with open(os.path.join(IMS_DIR, d["path"]), "r", encoding="utf-8") as fh: pendenzen_reg = parse_massnahmen(fh.read()) overdue_reviews = [ {"id": d["id"], "title": d["title"], "owner": d["meta"].get("owner", ""), "nextReview": d["nextReview"]} for d in docs if d["overdueReview"] ] zu_pruefen = [ {"id": d["id"], "title": d["title"], "section": s} for d in docs for s in d["zuPruefen"] ] status_counts = {} for d in docs: st = d["meta"].get("status", "unbekannt") status_counts[st] = status_counts.get(st, 0) + 1 open_reg = [p for p in pendenzen_reg if p["status"].lower() != "erledigt"] overdue_reg = [p for p in open_reg if (parse_date(p["faellig"]) and parse_date(p["faellig"]) < TODAY)] stats = { "docs": len(docs), "statusCounts": status_counts, "overdueReviews": len(overdue_reviews), "openPendenzen": len(open_reg), "overduePendenzen": len(overdue_reg), "generated": TODAY.isoformat(), } chapters = [{"key": k, "label": CHAPTER_LABELS[k]} for k in CHAPTER_ORDER] payload = { "docs": docs, "chapters": chapters, "pendenzenReg": pendenzen_reg, "overdueReviews": overdue_reviews, "zuPruefen": zu_pruefen, "stats": stats, "today": TODAY.isoformat(), } data_json = json.dumps(payload, ensure_ascii=False).replace("", "<\\/") mermaid_js = get_mermaid_js() html_out = HTML_TEMPLATE.replace("/*__DATA__*/", "window.IMS_DATA = " + data_json + ";") html_out = html_out.replace("/*__MERMAID__*/", mermaid_js) with open(OUT_FILE, "w", encoding="utf-8") as fh: fh.write(html_out) print("OK: %s (%d Dokumente, %d offene Pendenzen, %d überfällige Reviews)" % ( OUT_FILE, stats["docs"], stats["openPendenzen"], stats["overdueReviews"])) HTML_TEMPLATE = r"""