#!/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" % _inline(c) for c in _cells(header)) body = "" for r in rows: body += "" + "".join("%s" % _inline(c) for c in _cells(r)) + "" return "%s%s
" % (head, body) def md_to_html(md): lines = md.split("\n") out = [] i, n = 0, len(lines) while i < n: line = lines[i] if re.match(r"^\s*\s*$", line): i += 1 continue m = re.match(r"^```(\w*)\s*$", line.strip()) if m: lang = m.group(1) i += 1 buf = [] while i < n and not lines[i].strip().startswith("```"): buf.append(lines[i]) i += 1 i += 1 code = "\n".join(buf) if lang == "mermaid": out.append('
%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("
") i += 1 continue hm = re.match(r"^(#{1,6})\s+(.*)$", line) if hm: lvl = len(hm.group(1)) out.append("%s" % (lvl, _inline(hm.group(2).strip()), lvl)) i += 1 continue if "|" in line and i + 1 < n and "-" in lines[i + 1] and re.match(r"^\s*\|?[\s:|-]+\|?\s*$", lines[i + 1]): header = line i += 2 rows = [] while i < n and "|" in lines[i] and lines[i].strip() != "": rows.append(lines[i]) i += 1 out.append(_table(header, rows)) continue if re.match(r"^\s*>\s?", line): buf = [] while i < n and re.match(r"^\s*>\s?", lines[i]): buf.append(re.sub(r"^\s*>\s?", "", lines[i])) i += 1 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(item)) i += 1 out.append("<%s>%s" % (tag, "".join(items), tag)) continue buf = [line] i += 1 while i < n and lines[i].strip() != "" and not re.match( r"^(#{1,6})\s|^\s*```|^\s*([-*+])\s+|^\s*\d+\.\s+|^\s*>\s?", lines[i] ) and "|" not in lines[i]: buf.append(lines[i]) i += 1 out.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(" PowerOn IMS - Management Cockpit
    Dashboard
    """ if __name__ == "__main__": build()