"""FlowDeck — Export service (v4.7.2). 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) Supports the three ways a page's content can be stored: - content_format == "blocks" -> JSON list of blocks in ``content`` - content_format == "markdown" -> raw Markdown in ``content`` - content_format == "file" -> ``content`` is JSON metadata; the real text lives in an uploaded file on disk (uploads/workspace_*). We read it back so an exported document carries its actual content, not just its title. """ from __future__ import annotations import io import json import os import re import zipfile from pathlib import Path 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) # ── Source resolution: read real textual content for ANY page type ── # Extensions whose content is plain text / code / markdown (textual exportable). _TEXTUAL_EXTS = { "md", "markdown", "txt", "log", "text", "py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh", "ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp", "php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro", "properties", "gitignore", "dockerfile", "makefile", } _CODE_LANG = { "py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript", "tsx": "typescript", "html": "html", "htm": "html", "css": "css", "json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml", "toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini", "sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell", "bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust", "java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp", "php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala", "sql": "sql", "r": "r", "vue": "html", "svelte": "html", "astro": "html", "properties": "ini", "md": "markdown", "markdown": "markdown", "txt": "plaintext", "log": "plaintext", "text": "plaintext", } _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"} def _data_root() -> Path: """Directory that contains ``uploads/`` (mirrors dashboard.py /data).""" return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) def _file_meta(page: dict) -> dict: try: meta = json.loads(page.get("content") or "{}") return meta if isinstance(meta, dict) else {} except (json.JSONDecodeError, TypeError): return {} def _file_text(page: dict) -> str | None: """Return the textual content of an uploaded ``file`` page, or None. Only reads plain-text / code / markdown files. Binary (PDF, images…) returns None and is skipped by exporters (nothing meaningful to include). """ if (page.get("content_format") or "") != "file": return None meta = _file_meta(page) rel = (meta.get("file_path") or "").replace("\\", "/").strip() if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"): return None name = (rel.rsplit("/", 1)[-1] or "").lower() ext = name.rsplit(".", 1)[-1] if "." in name else "" mime = (meta.get("mime_type") or "").lower() if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")): return None try: full = (_data_root() / rel).resolve() root = _data_root().resolve() if root not in full.parents: return None return full.read_text(encoding="utf-8", errors="replace") except (OSError, ValueError): return None def _page_source(page: dict): """Return (kind, payload) describing where the page's real content lives. kind ∈ {"blocks", "md", "code"}: - "blocks": payload is the block list (block editor pages) - "md" : payload is raw Markdown text - "code" : payload is (text, language) An empty/unsupported page yields ("blocks", []). """ fmt = (page.get("content_format") or "blocks") content = page.get("content") or "" if fmt == "blocks": return "blocks", _blocks_of(page) if fmt == "markdown": if content.strip(): return "md", content return "blocks", [] if fmt == "file": text = _file_text(page) if text is None: return "blocks", [] meta = _file_meta(page) name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower() ext = name.rsplit(".", 1)[-1] if "." in name else "" mime = (meta.get("mime_type") or "").lower() if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"): return "md", text lang = _CODE_LANG.get(ext, "plaintext") return "code", (text, lang) # Unknown format (e.g. legacy) -> try to dump as raw text if content.strip(): return "md", content return "blocks", [] # ── GFM pipe-table parsing (raw markdown → "table" block) ── _SEP_CELL = re.compile(r"^:?-+:?$") def _split_pipe_cells(line: str) -> list[str]: """Split a GFM pipe row into trimmed cell strings.""" s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|") and not s.endswith(r"\|"): s = s[:-1] # split on unescaped pipes cells: list[str] = [] cur: list[str] = [] i = 0 while i < len(s): ch = s[i] if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|": cur.append("|") i += 2 continue if ch == "|": cells.append("".join(cur).strip()) cur = [] i += 1 continue cur.append(ch) i += 1 cells.append("".join(cur).strip()) return cells def _is_table_delimiter(line: str) -> bool: s = line.strip() if not s: return False if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] cells = [c.strip() for c in s.split("|")] return bool(cells) and all(_SEP_CELL.match(c) for c in cells) def _parse_table_at(lines: list[str], i: int, n: int): """If a GFM table starts at index i (header row + delimiter row), return (table_block, next_index). Otherwise return None.""" header_cells = _split_pipe_cells(lines[i]) if len(header_cells) <= 1: return None if i + 1 >= n or not _is_table_delimiter(lines[i + 1]): return None sep_cells = _split_pipe_cells(lines[i + 1]) align = [] for c in sep_cells[: len(header_cells)]: c = c.strip() if c.startswith(":") and c.endswith(":"): align.append("center") elif c.endswith(":"): align.append("right") else: align.append("left") rows = [header_cells] j = i + 2 while j < n: s = lines[j].strip() if not s or not s.startswith("|"): break cells = _split_pipe_cells(lines[j]) rows.append(cells) j += 1 width = max(len(r) for r in rows) def pad(r): return r + [""] * (width - len(r)) align = (align + ["left"] * width)[:width] return ( { "type": "table", "has_header": True, "align": align, "rows": [pad(r) for r in rows], }, j, ) def _table_to_markdown(b: dict) -> str: rows = b.get("rows") or [] if not rows: return "" align = b.get("align") or [] width = max(len(r) for r in rows) align = (align + ["left"] * width)[:width] has_header = b.get("has_header", True) out: list[str] = [] def rowline(r): cells = list(r) + [""] * (width - len(r)) return "| " + " | ".join(cells) + " |" start = 0 if has_header: out.append(rowline(rows[0])) seps = [] for a in align: if a == "center": seps.append(":---:") elif a == "right": seps.append("---:") else: seps.append(":---") out.append("| " + " | ".join(seps) + " |") start = 1 for ri in range(start, len(rows)): out.append(rowline(rows[ri])) return "\n".join(out) def _table_to_html(b: dict) -> str: rows = b.get("rows") or [] if not rows: return "" align = b.get("align") or [] width = max(len(r) for r in rows) align = (align + ["left"] * width)[:width] def cell_html(tag, text, a): style = f' style="text-align:{a};"' if a and a != "left" else "" return f"<{tag}{style}>{_text(text)}{tag}>" has_header = b.get("has_header", True) first_col = b.get("first_col_header", False) header_rows = 1 if has_header else 0 head = "" if header_rows: head_rows = [] hr = rows[0] cells = list(hr) + [""] * (width - len(hr)) head_cells = [] for ci, c in enumerate(cells): tag = "th" if first_col and ci == 0 else "th" head_cells.append(cell_html(tag, c, align[ci])) head_rows.append("
{c}") elif t == "divider": parts.append("
{label}{c}")
elif t == "math":
parts.append(f'{_text(resolved)}
") except Exception: parts.append(f"[Synced block {synced_id}]
") 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;} .ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;} .ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;} .ftable th{background:#f6f8fa;font-weight:600;} .ftable tr:nth-child(even) td{background:#fcfcfd;} .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) body = blocks_to_html(_page_blocks(page)) meta_updated = page.get("updated_at") or "" footer = f"" sub_html = "" if include_children: for sub in _child_pages(page): sub_html += '\n