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)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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 ""
|
||||
Reference in New Issue
Block a user