feat(export): v4.7.0 Export — Markdown, PDF, HTML, Site statique
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped

- Service serveur app/services/export.py: conversion blocs vers Markdown / HTML / PDF
- 4 endpoints: /api/export/markdown|html|pdf|site/{page_id}
- Export Markdown complet (tous les blocs + images + sous-pages recursives)
- Export PDF via xhtml2pdf (pur Python, aucune lib systeme)
- Export HTML standalone self-contained (styles inline)
- Export Site: zip multi-pages (index.html + une page par sous-page)
- UI: menu 'More > Export' dans l'editeur (Markdown, HTML, PDF, Site .zip)
- Tests: 190 passing (6 nouveaux)
This commit is contained in:
2026-09-03 00:26:58 -04:00
parent f7f5bae336
commit 2bdf5e4166
11 changed files with 702 additions and 28 deletions
+160 -1
View File
@@ -2865,4 +2865,163 @@ def test_v460_save_load_blocks_preserves_children(client):
types = {b["type"] for b in data}
assert "columns" in types and "toggle" in types and "math" in types
col = next(b for b in data if b["type"] == "columns")
assert len(col["children"]) == 2
assert len(col["children"]) == 2
# ── v4.7.0: Export (Markdown / HTML / PDF / Site) ──
def _make_export_page(client, blocks=None, title="Export Page", parent_id=None):
"""Insert a user + page directly with blocks, return (pid, uid)."""
import uuid
import json as _json
from app.db import get_conn
login = "v470_" + uuid.uuid4().hex[:10]
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?, 'V470', ?)",
(login, login + "@t.com"))
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
payload = _json.dumps(blocks or [{"id": "p1", "type": "paragraph", "content": "Hello"}])
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_id) "
"VALUES (?, ?, ?, 'blocks', ?)",
(login, title, payload, parent_id),
)
pid = cur.lastrowid
conn.commit()
return pid, uid
def _cleanup_export(client, pid, uid):
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE parent_id=? OR id=?", (pid, pid))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
BLOCKS_EXPORT = [
{"id": "h1", "type": "heading_1", "content": "Header"},
{"id": "p1", "type": "paragraph", "content": "Some paragraph"},
{"id": "b1", "type": "bulleted_list", "content": "Item A"},
{"id": "n1", "type": "numbered_list", "content": "Step 1"},
{"id": "td", "type": "to_do", "content": "Do it", "checked": True},
{"id": "q1", "type": "quote", "content": "A quote"},
{"id": "cd", "type": "code", "content": "print(1)", "language": "python"},
{"id": "dv", "type": "divider"},
{"id": "mt", "type": "math", "content": "E=mc^2"},
{"id": "tb", "type": "table_of_contents", "content": ""},
{"id": "tg", "type": "toggle", "content": "Toggle", "children": [
{"id": "t1", "type": "paragraph", "content": "Nested"},
]},
{"id": "cl", "type": "columns", "children": [
{"id": "c1", "type": "paragraph", "content": "Col A"},
{"id": "c2", "type": "paragraph", "content": "Col B"},
]},
{"id": "ct", "type": "callout", "content": "Callout msg", "icon": "💡"},
{"id": "im", "type": "image", "content": "", "src": "https://example.com/x.png", "alt": "pic"},
]
def test_v470_export_markdown(client):
"""Markdown export returns correct content for all block types."""
pid, uid = _make_export_page(client, BLOCKS_EXPORT, title="MD Page")
try:
resp = client.get(f"/api/export/markdown/{pid}")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/markdown")
assert 'filename="MD Page.md"' in resp.headers["content-disposition"]
body = resp.text
assert "# Header" in body
assert "Some paragraph" in body
assert "- Item A" in body
assert "- [x] Do it" in body
assert "> A quote" in body
assert "```python" in body
assert "$$\nE=mc^2\n$$" in body
assert "Callout msg" in body
assert "![pic](https://example.com/x.png)" in body
finally:
_cleanup_export(client, pid, uid)
def test_v470_export_markdown_includes_subpages(client):
"""Markdown export recursively includes sub-pages."""
pid, uid = _make_export_page(client, [{"id": "p1", "type": "paragraph", "content": "Root"}], "Root")
sub_pid, _ = _make_export_page(client, [{"id": "s1", "type": "paragraph", "content": "Child body"}], "Child", parent_id=pid)
try:
resp = client.get(f"/api/export/markdown/{pid}")
assert resp.status_code == 200
body = resp.text
assert "# Root" in body
assert "# Child" in body
assert "Child body" in body
finally:
with __import__("app.db", fromlist=["get_conn"]).get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=? OR id=?", (sub_pid, pid))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_v470_export_html(client):
"""HTML export is a standalone document with rendered blocks."""
pid, uid = _make_export_page(client, BLOCKS_EXPORT, title="HTML Page")
try:
resp = client.get(f"/api/export/html/{pid}")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert 'filename="HTML Page.html"' in resp.headers["content-disposition"]
body = resp.text
assert "<!DOCTYPE html>" in body
assert "<title>HTML Page</title>" in body
assert "<h1" in body
assert "<blockquote>" in body
assert "<pre>" in body and "print(1)" in body
assert "<details" in body
assert "callout" in body
assert "columns" in body
finally:
_cleanup_export(client, pid, uid)
def test_v470_export_pdf(client):
"""PDF export returns a valid PDF binary."""
pid, uid = _make_export_page(client, BLOCKS_EXPORT, title="PDF Page")
try:
resp = client.get(f"/api/export/pdf/{pid}")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/pdf")
assert 'filename="PDF Page.pdf"' in resp.headers["content-disposition"]
body = resp.content
assert body[:5] == b"%PDF-"
assert len(body) > 500
finally:
_cleanup_export(client, pid, uid)
def test_v470_export_site_zip(client):
"""Static site export returns a zip containing index + page html."""
import zipfile
import io
pid, uid = _make_export_page(client, [{"id": "p1", "type": "paragraph", "content": "Root body"}], "Root")
sub_pid, _ = _make_export_page(client, [{"id": "s1", "type": "paragraph", "content": "Sub body"}], "Child", parent_id=pid)
try:
resp = client.get(f"/api/export/site/{pid}")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/zip")
z = zipfile.ZipFile(io.BytesIO(resp.content))
names = z.namelist()
assert "index.html" in names
assert any(n.endswith(".html") for n in names)
index = z.read("index.html").decode("utf-8")
assert "Root" in index and "Child" in index
finally:
with __import__("app.db", fromlist=["get_conn"]).get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=? OR id=?", (sub_pid, pid))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_v470_export_404(client):
"""Export endpoints return 404 for missing pages."""
resp = client.get("/api/export/markdown/999999")
assert resp.status_code == 404