feat(export): v4.7.0 Export — Markdown, PDF, HTML, Site statique
- 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:
+160
-1
@@ -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 "" 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
|
||||
Reference in New Issue
Block a user