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
+19
View File
@@ -1,5 +1,24 @@
# Changelog — FlowDeck # 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 ## v4.6.0 (2026-09-02) — Content Blocks Enrichis
### Added ### Added
+17 -7
View File
@@ -270,10 +270,20 @@ Détails livrés :
- Export Markdown étendu aux nouveaux blocs - Export Markdown étendu aux nouveaux blocs
- 183 tests au total (dont 5 nouveaux tests v4.6.0) - 183 tests au total (dont 5 nouveaux tests v4.6.0)
### v4.7.0 — Export ### v4.7.0 — Export ✅ (2026-09-03)
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor) > **Objectif** : exporter une page (ou un site) depuis l'éditeur.
- [ ] **Export PDF** — mise en page fidèle, table des matières
- [ ] **Export HTML** — site statique standalone - [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 ### v4.8.0 — Collaboration
- [ ] **Inline comments** — commentaires sur sélection de texte - [ ] **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 + UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library + 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 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é ✅*
+1 -1
View File
@@ -1 +1 @@
4.6.0 4.7.0
+4 -3
View File
@@ -13,7 +13,7 @@ from app.config import settings
from app.db import init_db from app.db import init_db
from app.middleware.csrf import CSRFMiddleware from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware 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.gitea import router as gitea_router
from app.routers.github_routes import router as github_router from app.routers.github_routes import router as github_router
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
@@ -39,13 +39,13 @@ async def lifespan(_app: FastAPI):
(admin_hash,) (admin_hash,)
) )
conn.commit() 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 yield
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="4.6.0", version="4.7.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
@@ -73,6 +73,7 @@ app.include_router(github_router)
app.include_router(public_api.router) app.include_router(public_api.router)
app.include_router(sharing.router) app.include_router(sharing.router)
app.include_router(sidebar_config.router) app.include_router(sidebar_config.router)
app.include_router(export.router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
+93
View File
@@ -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)
+365
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
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"![{alt}]({src})")
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()
+9 -1
View File
@@ -345,7 +345,15 @@
@click.outside="!_justOpened && (moreOpen = false)" @click.outside="!_justOpened && (moreOpen = false)"
x-transition 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"> <div class="more-menu-item" @click="duplicatePage">
{{ fd_icon("copy",14) }} Duplicate {{ fd_icon("copy",14) }} Duplicate
</div> </div>
+24 -15
View File
@@ -137,7 +137,7 @@
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null, pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
updatedAt:'{{ page.get("updated_at", "") }}', updatedAt:'{{ page.get("updated_at", "") }}',
fmt:{open:false,top:0,left:0,idx:-1}, 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, moveOpen:false,moveWorkspaces:[],moveLoading:false,
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}', activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
shareTab:'share',favorited:false, 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='';}, 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);}, removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
exportPage(){ exportPage(fmt){
this.moreOpen=false; this.moreOpen=false;
// Export blocks as Markdown this.exportOpen=false;
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n'; // Ensure latest blocks are persisted before exporting any format
for (var i = 0; i < this.blocks.length; i++) { var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var b = this.blocks[i]; var self = this;
md += blocksToMarkdown(b) + '\n\n'; 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')}&section=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');});}, duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=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(){ 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();}}, 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();}, 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);}, autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
save(){ save(cb){
if(this.fileData)return; if(this.fileData)return;
if(this.saving)return; if(this.saving){ if(cb) cb(); return; }
this.sync(); this.sync();
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page'; const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
this.saving=true; 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;})})}) 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(); .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';}); 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;}); }).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;}, 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;},
+1
View File
@@ -11,3 +11,4 @@ python-dotenv==1.0.*
packaging>=24.0 packaging>=24.0
itsdangerous==2.2.* itsdangerous==2.2.*
slowapi==0.1.* slowapi==0.1.*
xhtml2pdf==0.2.*
+9
View File
@@ -3212,6 +3212,15 @@ button.breadcrumb-current { max-width: 260px; }
.more-menu-item.danger { color: var(--danger); } .more-menu-item.danger { color: var(--danger); }
.more-menu-sep { height: 1px; background: var(--border); margin: 4px 0; } .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 ── */
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; } .toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; }
.toggle-switch.sm { width: 32px; height: 18px; } .toggle-switch.sm { width: 32px; height: 18px; }
+160 -1
View File
@@ -2865,4 +2865,163 @@ def test_v460_save_load_blocks_preserves_children(client):
types = {b["type"] for b in data} types = {b["type"] for b in data}
assert "columns" in types and "toggle" in types and "math" in types assert "columns" in types and "toggle" in types and "math" in types
col = next(b for b in data if b["type"] == "columns") 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