- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
"""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 ``<pre>`` 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'<figure class="mermaid-figure">{svg}</figure>'
|
|
return (f'<pre class="mermaid"><code class="language-mermaid">'
|
|
f'{html.escape(source or "")}</code></pre>')
|
|
|
|
|
|
# ── 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'<span class="fd-equation" data-equation="{html.escape(expr)}">'
|
|
f'\\({html.escape(expr)}\\)</span>')
|
|
|
|
|
|
# ── 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'<div class="fd-progress" data-percent=""><div class="fd-progress-label">'
|
|
f'{html.escape(label)}: —</div></div>')
|
|
detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else ""
|
|
return (f'<div class="fd-progress" data-percent="{pct}">'
|
|
f'<div class="fd-progress-label">{html.escape(label)}: {pct}%'
|
|
f'{(" (" + html.escape(detail) + ")") if detail else ""}</div>'
|
|
f'<div class="fd-progress-bar"><div class="fd-progress-fill"'
|
|
f' style="width:{min(pct, 100)}%"></div></div></div>')
|
|
|
|
|
|
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 ""
|