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"![{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'

{c}

') + elif t == "heading_2": + parts.append(f'

{c}

') + elif t == "heading_3": + parts.append(f'

{c}

') + elif t == "heading_4": + parts.append(f'

{c}

') + elif t == "bulleted_list": + parts.append(f"
  • {c}
  • ") + elif t == "numbered_list": + parts.append(f"
  • {c}
  • ") + 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'
    ' + f'{c}
    ' + ) + elif t == "toggle": + children = blocks_to_html(b.get("children") or []) + parts.append(f"
    {c}{children}
    ") + elif t == "quote": + parts.append(f"
    {c}
    ") + elif t == "divider": + parts.append("
    ") + elif t == "code": + lang = b.get("language") or "" + label = f'
    {_text(lang)}
    ' if lang else "" + parts.append(f"
    {label}{c}
    ") + elif t == "math": + parts.append(f'
    \\[{c}\\]
    ') + 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'
    ' + f'{_text(x.get("content"))}
    ' + for x in toc + ) + parts.append(f'') + elif t == "columns": + cols = "".join( + f'
    {blocks_to_html([child])}
    ' + for child in (b.get("children") or []) + ) + parts.append(f'
    {cols}
    ') + elif t == "callout": + icon = b.get("icon") or "💡" + bg = (b.get("style") or {}).get("bgColor", "#eef2ff") + parts.append(f'
    {_text(icon, escape=False)}
    {c}
    ') + elif t == "image": + src = b.get("src") or "" + alt = _text(b.get("alt")) + parts.append(f'
    {alt}
    {alt}
    ') + else: + 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
    \n
    ' + sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url) + sub_html += "
    " + + return f""" + + + + +{_text(title)} + + + +
    +

    {_text(title)}

    +{body} +{sub_html} +{footer} +
    + +""" + + +# ═══════════════ 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"

    {_text(line)}

    " for line in (page.get("content") or "").splitlines()) + else: + body = blocks_to_html(content_inner) + return f"""{_text(title)} + +

    {_text(title)}

    +{body} + +""" + + +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'
  • {_text(title)}
  • ' + + items = "".join(link(p) for p in pages) + return f""" + +FlowDeck Site + +

    FlowDeck Site

    """ + + +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() diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 0d37405..ae804fd 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -345,7 +345,15 @@ @click.outside="!_justOpened && (moreOpen = false)" x-transition > -
    ↗ Export
    +
    ↗ Export
    +
    {{ fd_icon("copy",14) }} Duplicate
    diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 62aec1b..81fdee8 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -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;}, diff --git a/requirements.txt b/requirements.txt index 1161096..19679f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ python-dotenv==1.0.* packaging>=24.0 itsdangerous==2.2.* slowapi==0.1.* +xhtml2pdf==0.2.* diff --git a/static/css/app.css b/static/css/app.css index e34c55f..eec0c01 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } diff --git a/tests/test_app.py b/tests/test_app.py index 47ab9c1..4dd49f3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 \ No newline at end of file + 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 "" in body + assert "HTML Page" in body + assert "" in body + assert "
    " 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