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:
@@ -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
|
||||
|
||||
+17
-7
@@ -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é ✅*
|
||||
|
||||
+4
-3
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "numbered_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
|
||||
parts.append(
|
||||
f'<div class="todo"><input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{style}">{c}</span></div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children = blocks_to_html(b.get("children") or [])
|
||||
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
|
||||
elif t == "quote":
|
||||
parts.append(f"<blockquote>{c}</blockquote>")
|
||||
elif t == "divider":
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
toc = [x for x in (blocks or [])
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
|
||||
if toc:
|
||||
items = "".join(
|
||||
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
|
||||
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
|
||||
for x in toc
|
||||
)
|
||||
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
|
||||
elif t == "columns":
|
||||
cols = "".join(
|
||||
f'<div class="column">{blocks_to_html([child])}</div>'
|
||||
for child in (b.get("children") or [])
|
||||
)
|
||||
parts.append(f'<div class="columns">{cols}</div>')
|
||||
elif t == "callout":
|
||||
icon = b.get("icon") or "💡"
|
||||
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
|
||||
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
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"<p>{_text(line)}</p>" for line in (page.get("content") or "").splitlines())
|
||||
else:
|
||||
body = blocks_to_html(content_inner)
|
||||
|
||||
meta_updated = page.get("updated_at") or ""
|
||||
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
||||
|
||||
sub_html = ""
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
|
||||
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
|
||||
sub_html += "</div>"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{_text(title)}</title>
|
||||
<style>{_standalone_css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
{sub_html}
|
||||
{footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ═══════════════ PDF ═══════════════
|
||||
|
||||
def _pdf_html(page: dict) -> str:
|
||||
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
||||
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"<p>{_text(line)}</p>" for line in (page.get("content") or "").splitlines())
|
||||
else:
|
||||
body = blocks_to_html(content_inner)
|
||||
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
||||
<style>
|
||||
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
||||
h1{{font-size:26px;margin:0 0 10px;}}
|
||||
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
|
||||
h3{{font-size:16px;margin:18px 0 6px;}}
|
||||
h4{{font-size:14px;margin:14px 0 4px;}}
|
||||
p,li{{margin:4px 0;}}
|
||||
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
|
||||
code{{font-family:monospace;font-size:10px;}}
|
||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||
table{{border-collapse:collapse;width:100%;}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
.todo{{margin:4px 0;}}
|
||||
.math{{font-style:italic;margin:10px 0;}}
|
||||
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
|
||||
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
|
||||
</style></head><body>
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def page_to_pdf_bytes(page: dict) -> bytes:
|
||||
"""Render a page to a PDF (via xhtml2pdf)."""
|
||||
from xhtml2pdf import pisa
|
||||
src = _pdf_html(page)
|
||||
buf = io.BytesIO()
|
||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||
if pdf.err:
|
||||
raise RuntimeError(f"PDF generation failed: {pdf.err}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════ Static site (zip) ═══════════════
|
||||
|
||||
def _site_index_html(pages: list[dict]) -> str:
|
||||
"""Build the index.html of the static site (list of all pages)."""
|
||||
def link(p: dict) -> str:
|
||||
title = _page_title(p)
|
||||
slug = quote(f"{title}", safe="")
|
||||
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
||||
|
||||
items = "".join(link(p) for p in pages)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<title>FlowDeck Site</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
|
||||
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
|
||||
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
|
||||
|
||||
|
||||
def build_static_site_bytes(root_page: dict) -> bytes:
|
||||
"""Build a full static site as a zip: index.html + one HTML file per page."""
|
||||
pages = [root_page] + _child_pages(root_page)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("index.html", _site_index_html(pages))
|
||||
for p in pages:
|
||||
title = _page_title(p)
|
||||
name = f"{quote(title, safe='')}.html"
|
||||
z.writestr(name, page_to_standalone_html(p, include_children=False))
|
||||
return buf.getvalue()
|
||||
@@ -345,7 +345,15 @@
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
||||
<div class="more-menu-item" @click="moreOpen = false; exportOpen = true">↗ Export</div>
|
||||
<template x-if="exportOpen">
|
||||
<div class="more-menu-sub">
|
||||
<div class="more-menu-item" @click="exportPage('markdown')"> Markdown (.md)</div>
|
||||
<div class="more-menu-item" @click="exportPage('html')"> HTML (.html)</div>
|
||||
<div class="more-menu-item" @click="exportPage('pdf')"> PDF (.pdf)</div>
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
||||
updatedAt:'{{ page.get("updated_at", "") }}',
|
||||
fmt:{open:false,top:0,left:0,idx:-1},
|
||||
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
||||
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,exportOpen:false,
|
||||
moveOpen:false,moveWorkspaces:[],moveLoading:false,
|
||||
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
|
||||
shareTab:'share',favorited:false,
|
||||
@@ -341,20 +341,28 @@
|
||||
},
|
||||
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
||||
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
||||
exportPage(){
|
||||
exportPage(fmt){
|
||||
this.moreOpen=false;
|
||||
// Export blocks as Markdown
|
||||
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n';
|
||||
for (var i = 0; i < this.blocks.length; i++) {
|
||||
var b = this.blocks[i];
|
||||
md += blocksToMarkdown(b) + '\n\n';
|
||||
this.exportOpen=false;
|
||||
// Ensure latest blocks are persisted before exporting any format
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
var doDownload = function(){
|
||||
var title = encodeURIComponent((self.pageTitle || 'Untitled').trim() || 'Untitled');
|
||||
var a = document.createElement('a');
|
||||
a.href = '/api/export/' + (fmt==='markdown'?'markdown':fmt==='html'?'html':fmt==='pdf'?'pdf':'site') + '/' + self.pid;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
var label = {markdown:'Markdown',html:'HTML',pdf:'PDF',site:'Site'}[fmt]||'';
|
||||
self.showToast('Exported as ' + label);
|
||||
};
|
||||
if (this.dirty) {
|
||||
this.save(function(){ doDownload(); });
|
||||
} else {
|
||||
doDownload();
|
||||
}
|
||||
var blob = new Blob([md], {type: 'text/markdown'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = (this.pageTitle || 'export') + '.md';
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
this.showToast('Exported as Markdown');
|
||||
},
|
||||
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
movePage(){
|
||||
@@ -518,9 +526,9 @@
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
||||
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
save(){
|
||||
save(cb){
|
||||
if(this.fileData)return;
|
||||
if(this.saving)return;
|
||||
if(this.saving){ if(cb) cb(); return; }
|
||||
this.sync();
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
||||
this.saving=true;
|
||||
@@ -528,6 +536,7 @@
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
|
||||
|
||||
@@ -11,3 +11,4 @@ python-dotenv==1.0.*
|
||||
packaging>=24.0
|
||||
itsdangerous==2.2.*
|
||||
slowapi==0.1.*
|
||||
xhtml2pdf==0.2.*
|
||||
|
||||
@@ -3212,6 +3212,15 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.more-menu-item.danger { color: var(--danger); }
|
||||
.more-menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
/* ── More menu sub-section (e.g. Export formats) ── */
|
||||
.more-menu-sub {
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
margin: 2px 0; padding: 2px 0 2px 6px;
|
||||
}
|
||||
.more-menu-sub .more-menu-item { font-size: 12.5px; padding: 6px 12px; }
|
||||
|
||||
/* ── Toggle switch ── */
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||||
.toggle-switch.sm { width: 32px; height: 18px; }
|
||||
|
||||
+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