"""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()