"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress. Server-side rendering so HTML/PDF export embeds real content (the editor already has Prism + KaTeX client-side). Design §2 of ``docs/V73_Wiki_Teamspaces_Polish.md``. - ``mermaid`` : SVG via ``mmdc`` when installed, else a ``
`` fallback
                    that mermaid.js can still render in the browser.
- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
                    falls back to readable plaintext.
- ``progress``    : ``{"rollup_ref": {collection_id, property_id}}`` → percent
                    bar computed with :class:`RollupEngine`.
"""
from __future__ import annotations

import html
import re
import shutil
import subprocess
import tempfile
from pathlib import Path

# ── mermaid ────────────────────────────────────────────────────────────────

def mmdc_available() -> bool:
    return shutil.which("mmdc") is not None


def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
    """Render mermaid source to an inline SVG, or ``None`` if unavailable.

    Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
    degrade to the code fallback.
    """
    source = (source or "").strip()
    if not source or not mmdc_available():
        return None
    with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
        src = Path(tmp) / "diagram.mmd"
        out = Path(tmp) / "diagram.svg"
        src.write_text(source, encoding="utf-8")
        try:
            subprocess.run(
                ["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
                capture_output=True, timeout=timeout, check=True)
        except (subprocess.SubprocessError, OSError):
            return None
        if not out.exists():
            return None
        svg = out.read_text(encoding="utf-8", errors="replace")
    # strip the XML prolog / doctype so the SVG can be inlined
    svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
    return svg or None


def render_mermaid(source: str) -> str:
    """Inline SVG when possible, else a mermaid-renderable code block."""
    svg = mermaid_to_svg(source)
    if svg:
        return f'
{svg}
' return (f'
'
            f'{html.escape(source or "")}
') # ── equations ────────────────────────────────────────────────────────────── # Only characters that are meaningful inside a math expression are kept. Note # that `<`, `>` and `\` are NOT allowed: they would let the source close the # KaTeX delimiter early or inject markup into the exported HTML. _EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]") def sanitize_equation(source: str) -> str: return _EQUATION_ALLOWED.sub("", source or "").strip() def render_equation(source: str) -> str: expr = sanitize_equation(source) if not expr: return "" return (f'' f'\\({html.escape(expr)}\\)') # ── progress ─────────────────────────────────────────────────────────────── def _rows_values(collection_id: int) -> list[dict]: import json as _json from app.db import get_conn with get_conn() as conn: rows = conn.execute( "SELECT property_values_json FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchall() out = [] for r in rows: try: out.append(_json.loads(r["property_values_json"] or "{}")) except ValueError: out.append({}) return out _DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed") def _is_done(value) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): return value.strip().lower() in _DONE_TRUE return False def progress_value(rollup_ref: dict | None) -> dict: """Resolve ``{collection_id, done_property_id | property_id, func}`` to a 0..100 percent. Aggregates directly over ``collection_pages.property_values_json`` (the relation-based :class:`RollupEngine` needs a relation chain + a source page, which a page-embedded progress bar does not have). """ from app.db import get_conn if not rollup_ref or not rollup_ref.get("collection_id"): return {"percent": None, "done": None, "total": None} cid = rollup_ref["collection_id"] values = _rows_values(cid) total = len(values) done_pid = rollup_ref.get("done_property_id") if done_pid: key = str(done_pid) done = sum(1 for vals in values if _is_done(vals.get(key))) if total: return {"percent": round(done * 100 / total, 1), "done": done, "total": total} return {"percent": 0.0, "done": 0, "total": 0} pid = rollup_ref.get("property_id") if not pid or not total: return {"percent": None, "done": None, "total": total} key = str(pid) with get_conn() as conn: prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND" " collection_id=?", (pid, cid)).fetchone() if not prop: return {"percent": None, "done": None, "total": total} func = (rollup_ref.get("func") or "count").lower() if prop["prop_type"] in ("checkbox", "status", "select"): done = sum(1 for vals in values if _is_done(vals.get(key))) elif prop["prop_type"] in ("number", "formula", "rollup"): nums = [] for vals in values: v = vals.get(key) try: nums.append(float(v)) except (TypeError, ValueError): continue if not nums: return {"percent": None, "done": None, "total": total} done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs), "max": max, "min": min}.get(func, len)(nums) else: done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {})) if not total: return {"percent": 0.0, "done": done, "total": 0} return {"percent": round(min(float(done) * 100 / total, 100), 1), "done": done, "total": total} def render_progress(block: dict) -> str: stats = progress_value(block.get("rollup_ref") or block) pct = stats["percent"] label = block.get("label") or "Progress" if pct is None: return (f'
' f'{html.escape(label)}: —
') detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else "" return (f'
' f'
{html.escape(label)}: {pct}%' f'{(" (" + html.escape(detail) + ")") if detail else ""}
' f'
') def render_block(block: dict) -> str: """Dispatch for the three v7.3 block types (used by export + preview API).""" t = block.get("type") if t == "mermaid": return render_mermaid(block.get("content") or block.get("source") or "") if t == "equation_inline": return render_equation(block.get("content") or "") if t == "progress": return render_progress(block) return ""