Files
flowdeck/app/services/wiki_blocks.py
T
bruno 1706ad1ee9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped
feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- 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)
2026-09-30 20:02:57 -04:00

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 ""