diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce107b..a5d1c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog — FlowDeck +## v4.7.0 (2026-09-03) — Export + +### Added +- **Export Markdown** — conversion serveur complète de tous les blocs (headings, listes, to-do, quote, code, math, toggle, colonnes, callout, image), images incluses +- **Export Markdown récursif** — les sous-pages sont incluses automatiquement +- **Export PDF** — via `xhtml2pdf` (pur Python, aucun lib système), rendu print-friendly +- **Export HTML** — document autonome self-contained (styles inline) +- **Export Site** — site statique multi-pages en `.zip` (index.html + une page HTML par sous-page) +- **4 endpoints serveur** : `/api/export/markdown|html|pdf|site/{page_id}` +- **UI** : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip) + +### Améliorations +- Nouveau service `app/services/export.py` (conversion blocs → Markdown / HTML / PDF réutilisable) +- Nouveau routeur `app/routers/export.py` +- Téléchargements via `Content-Disposition` (avec nom de fichier encodé UTF-8) + +### Tests +- **190 tests au total** (6 nouveaux tests v4.7.0) : Markdown, sous-pages, HTML, PDF, Site zip, 404 + ## v4.6.0 (2026-09-02) — Content Blocks Enrichis ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 49ab80b..d026855 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -270,10 +270,20 @@ Détails livrés : - Export Markdown étendu aux nouveaux blocs - 183 tests au total (dont 5 nouveaux tests v4.6.0) -### v4.7.0 — Export -- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor) -- [ ] **Export PDF** — mise en page fidèle, table des matières -- [ ] **Export HTML** — site statique standalone +### v4.7.0 — Export ✅ (2026-09-03) +> **Objectif** : exporter une page (ou un site) depuis l'éditeur. + +- [x] **Export Markdown** — avec images et sous-pages (récursif) +- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système) +- [x] **Export HTML** — site statique standalone (document autonome + site .zip) + +Détails livrés : +- Service serveur `app/services/export.py` : conversion blocs → Markdown / HTML / PDF +- 4 formats exposés : `/api/export/markdown|html|pdf|site/{page_id}` +- Sous-pages incluses récursivement (Markdown et site) +- UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip) +- PDF généré via `xhtml2pdf` (pip) — aucun lib système requise +- 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404) ### v4.8.0 — Collaboration - [ ] **Inline comments** — commentaires sur sélection de texte @@ -423,8 +433,8 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜ +v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8–4.9 ⬜ v5.x–v6.0 ⬜ Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent -& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur) +& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML Agent IA (futur) -*Dernière mise à jour: 2026-09-02 — v4.6.0 Content Blocks enrichis complété ✅* +*Dernière mise à jour: 2026-09-03 — v4.7.0 Export complété ✅* diff --git a/VERSION b/VERSION index 28446a5..1163055 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.6.0 \ No newline at end of file +4.7.0 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 4ea8903..b6c2d4f 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router from app.services.gitea_client import gitea @@ -39,13 +39,13 @@ async def lifespan(_app: FastAPI): (admin_hash,) ) conn.commit() - logger.info("FlowDeck v4.6.0 started on port %d", settings.app_port) + logger.info("FlowDeck v4.7.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="4.6.0", + version="4.7.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -73,6 +73,7 @@ app.include_router(github_router) app.include_router(public_api.router) app.include_router(sharing.router) app.include_router(sidebar_config.router) +app.include_router(export.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/routers/export.py b/app/routers/export.py new file mode 100644 index 0000000..00694f2 --- /dev/null +++ b/app/routers/export.py @@ -0,0 +1,93 @@ +"""FlowDeck — Export endpoints (v4.7.0). + +Routes /api/export/* — generate Markdown, HTML, PDF and static-site (zip) +exports server-side for a given page. +""" +from __future__ import annotations + +import logging +import re + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import Response + +from app.db import get_conn +from app.services.export import ( + page_to_markdown, + page_to_pdf_bytes, + page_to_standalone_html, + build_static_site_bytes, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["export"], prefix="/api/export") + + +def _load_page_or_404(page_id: int) -> dict: + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Page not found") + return dict(row) + + +def _download_header(filename: str, media_type: str) -> dict: + ascii_name = re.sub(r"[^\x00-\x7F]", "_", filename) + quoted = filename.replace('"', '') + return { + "Content-Disposition": f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quoted}', + "Cache-Control": "no-store", + "Content-Type": media_type, + } + + +def _safe_filename(page: dict, ext: str) -> str: + title = (page.get("title") or "Untitled").strip() or "Untitled" + title = re.sub(r'[\\/:*?"<>|]+', "_", title) + return f"{title}.{ext}" + + +@router.get("/markdown/{page_id}") +async def export_markdown(page_id: int, request: Request): + page = _load_page_or_404(page_id) + md = page_to_markdown(page) + filename = _safe_filename(page, "md") + headers = _download_header(filename, "text/markdown") + return Response(content=md.encode("utf-8"), status_code=200, headers=headers) + + +@router.get("/html/{page_id}") +async def export_html(page_id: int, request: Request): + page = _load_page_or_404(page_id) + html = page_to_standalone_html(page) + filename = _safe_filename(page, "html") + headers = _download_header(filename, "text/html") + return Response(content=html.encode("utf-8"), status_code=200, headers=headers) + + +@router.get("/pdf/{page_id}") +async def export_pdf(page_id: int, request: Request): + page = _load_page_or_404(page_id) + try: + pdf_bytes = page_to_pdf_bytes(page) + except ImportError: + raise HTTPException(status_code=501, detail="PDF export requires 'xhtml2pdf'") + except Exception as exc: # noqa: BLE001 + logger.error("PDF export failed for page %s: %s", page_id, exc) + raise HTTPException(status_code=500, detail="PDF generation failed") + filename = _safe_filename(page, "pdf") + headers = _download_header(filename, "application/pdf") + return Response(content=pdf_bytes, status_code=200, headers=headers) + + +@router.get("/site/{page_id}") +async def export_site(page_id: int, request: Request): + page = _load_page_or_404(page_id) + site_bytes = build_static_site_bytes(page) + title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site" + filename = f"{title}_site.zip" + headers = _download_header(filename, "application/zip") + return Response(content=site_bytes, status_code=200, headers=headers) diff --git a/app/services/export.py b/app/services/export.py new file mode 100644 index 0000000..441a831 --- /dev/null +++ b/app/services/export.py @@ -0,0 +1,365 @@ +"""FlowDeck — Export service (v4.7.0). + +Four types of export, all generated server-side: + - Markdown (``page_to_markdown``): title + blocks + récursif sous-pages + - HTML (``page_to_standalone_html``): document autonome (styles inline) + - PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly + - Site (``build_static_site``): site statique multi-pages (zip) +""" +from __future__ import annotations + +import io +import json +import zipfile +from urllib.parse import quote + +from app.db import get_conn + + +# ═══════════════ Helpers ═══════════════ + +def _text(v: str, *, escape: bool = True) -> str: + """Normalize a block's content string.""" + s = (v or "").replace("\r\n", "\n").replace("\r", "\n") + if escape: + s = (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">")) + return s + + +def _sanitize_id(block_id) -> str: + if not block_id: + return "" + return "".join(ch for ch in str(block_id) if ch.isalnum()) + + +def _page_title(page: dict) -> str: + return (page.get("title") or "Untitled").strip() or "Untitled" + + +def _blocks_of(page: dict) -> list: + content = page.get("content") or "" + fmt = page.get("content_format") or "blocks" + if fmt != "blocks" or not content: + return [] + try: + data = json.loads(content) + except (json.JSONDecodeError, TypeError): + return [] + return data if isinstance(data, list) else [] + + +def _block_text(b: dict) -> str: + return _text(b.get("content"), escape=False) + + +# ═══════════════ Markdown ═══════════════ + +def blocks_to_markdown(blocks: list) -> str: + """Convert a block array to Markdown (server-side, all block types).""" + out: list[str] = [] + for b in blocks or []: + t = b.get("type", "paragraph") + c = _block_text(b) + if t == "heading_1": + out.append(f"# {c}") + elif t == "heading_2": + out.append(f"## {c}") + elif t == "heading_3": + out.append(f"### {c}") + elif t == "heading_4": + out.append(f"#### {c}") + elif t == "bulleted_list": + out.append(f"- {c}") + elif t == "numbered_list": + out.append(f"1. {c}") + elif t == "to_do": + out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}") + elif t == "quote": + out.append(f"> {c}") + elif t == "divider": + out.append("---") + elif t == "code": + lang = b.get("language") or "" + out.append(f"```{lang}\n{c}\n```") + elif t == "toggle": + out.append(f"### {c}") + if b.get("children"): + out.append(blocks_to_markdown(b["children"])) + elif t == "math": + out.append(f"$$\n{c}\n$$") + elif t == "table_of_contents": + out.append("[TOC]") + elif t == "columns": + for child in b.get("children") or []: + out.append(blocks_to_markdown([child])) + elif t == "image": + src = b.get("src") or "" + alt = (b.get("alt") or "").strip() or "image" + out.append(f"") + else: + out.append(c) + return "\n\n".join(filter(None, out)) + + +def _child_pages(page: dict) -> list: + """Immediate non-deleted children of a page.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL " + "ORDER BY COALESCE(sort_order, created_at) ASC, id ASC", + (page["id"],), + ).fetchall() + return [dict(r) for r in rows] + + +def page_to_markdown(page: dict, *, include_children: bool = True) -> str: + """Markdown for a single page, with optional sub-pages appended.""" + parts = [f"# {_page_title(page)}", ""] + content_inner = _blocks_of(page) + if page.get("content_format") == "markdown" and (page.get("content") or "").strip(): + parts.append((page["content"] or "").strip()) + else: + md = blocks_to_markdown(content_inner) + if md: + parts.append(md) + md = "\n\n".join(filter(None, parts)).rstrip() + + if include_children: + for sub in _child_pages(page): + sub_md = page_to_markdown(sub, include_children=True) + if sub_md: + md += f"\n\n---\n\n{sub_md}" + return md + + +# ═══════════════ HTML ═══════════════ + +def blocks_to_html(blocks: list) -> str: + """Convert a block array to a self-contained HTML fragment.""" + parts: list[str] = [] + for b in blocks or []: + t = b.get("type", "paragraph") + c = _text(b.get("content")) + if t == "heading_1": + parts.append(f'
{c}") + elif t == "divider": + parts.append("
{label}{c}")
+ elif t == "math":
+ parts.append(f'{c}
") + return "\n".join(parts) + + +def _standalone_css() -> str: + return """ +:root{color-scheme:light;} +*{box-sizing:border-box;} +body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;} +.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;} +h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;} +h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;} +h3{font-size:1.35rem;margin:24px 0 8px;} +h4{font-size:1.1rem;margin:20px 0 6px;} +p{margin:8px 0;} +li{margin:4px 0;} +ol{list-style:decimal;padding-left:24px;} +ul{list-style:disc;padding-left:24px;} +blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;} +hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;} +pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;} +code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;} +pre code{background:none;padding:0;font-size:13px;} +.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;} +details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;} +details summary{cursor:pointer;font-weight:600;} +details[open] summary{margin-bottom:8px;} +.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;} +.todo input{margin-top:5px;} +.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;} +.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;} +.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;} +.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;} +.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;} +.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;} +.callout>span{font-size:20px;flex-shrink:0;} +.math{margin:14px 0;overflow-x:auto;} +figure{margin:16px 0;text-align:center;} +figure img{max-width:100%;border-radius:8px;} +figcaption{font-size:13px;color:#8b949e;margin-top:6px;} +.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;} +a{color:#0969da;} +@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}} +""" + + +def page_to_standalone_html( + page: dict, + *, + include_children: bool = True, + base_url: str = "", +) -> str: + """Return a standalone, self-contained HTML document for a page.""" + title = _page_title(page) + content_inner = _blocks_of(page) + if page.get("content_format") == "markdown" and (page.get("content") or "").strip(): + body = "\n".join(f"{_text(line)}
" for line in (page.get("content") or "").splitlines()) + else: + body = blocks_to_html(content_inner) + + meta_updated = page.get("updated_at") or "" + footer = f"" + + sub_html = "" + if include_children: + for sub in _child_pages(page): + sub_html += '\n{_text(line)}
" for line in (page.get("content") or "").splitlines()) + else: + body = blocks_to_html(content_inner) + return f"""" in body and "print(1)" in body + assert "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 \ No newline at end of file