"""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 re import zipfile from pathlib import Path from urllib.parse import quote from app.config import settings 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). # Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for # the viewer lives client-side; this set gates server-side text handling: # file viewer, copy-to-clipboard, exports, imports). _TEXTUAL_EXTS = { "md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd", "txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc", "py", "pyw", "pyi", "gyp", "gypi", "js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx", "html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt", "rss", "atom", "plist", "wsf", "svg", "axml", "vue", "svelte", "astro", "css", "scss", "sass", "less", "json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties", "inf", "reg", "url", "prop", "gitconfig", "gitmodules", "editorconfig", "sh", "bash", "zsh", "ksh", "mksh", "yash", "fish", "ps1", "psm1", "psd1", "bat", "cmd", "rb", "gemspec", "rake", "rbw", "go", "rs", "java", "jsp", "kt", "kts", "scala", "sc", "groovy", "gvy", "gradle", "c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp", "cs", "csx", "cake", "ino", "pde", "php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql", "r", "jl", "lua", "dart", "pl", "pm", "pod", "hs", "lhs", "ex", "exs", "eex", "heex", "leex", "erl", "hrl", "escript", "clj", "cljs", "cljc", "edn", "ml", "mli", "fs", "fsi", "fsx", "fsscript", "nim", "nims", "d", "di", "m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr", "vhd", "vhdl", "v", "vh", "sv", "svh", "asm", "s", "nasm", "inc", "ada", "ads", "adb", "f", "for", "f90", "f95", "f03", "f08", "cob", "cbl", "cpy", "dockerfile", "containerfile", "tf", "tfvars", "hcl", "cmake", "makefile", "mk", "mak", "ninja", "gql", "graphql", "graphqls", "proto", "thrift", "vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc", "http", "rest", "nginx", "apache", "htaccess", "wat", "diff", "patch", "rej", "tex", "latex", "sty", "cls", "dtx", "bib", "bbl", "gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore", "lock", "mod", "sum", } # Well-known extensionless / dotfile basenames that are textual code. _TEXTUAL_FILENAMES = { "dockerfile", "containerfile", "makefile", "gnumakefile", "cmakelists.txt", "rakefile", "gemfile", "vagrantfile", "berksfile", "brewfile", "dangerfile", "fastfile", "podfile", "go.mod", "go.sum", ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc", ".vimrc", ".gvimrc", ".viminfo", ".gitconfig", ".editorconfig", ".htaccess", } # Exact basenames → highlight.js id (checked before the extension). _FILENAME_LANG = { "dockerfile": "dockerfile", "containerfile": "dockerfile", "makefile": "makefile", "gnumakefile": "makefile", "cmakelists.txt": "cmake", "rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby", "berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby", "fastfile": "ruby", "podfile": "ruby", "go.mod": "go", "nginx.conf": "nginx", ".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash", ".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash", ".profile": "bash", ".shrc": "bash", ".kshrc": "bash", ".vimrc": "vim", ".gvimrc": "vim", ".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache", } _CODE_LANG = { "py": "python", "pyw": "python", "pyi": "python", "gyp": "python", "gypi": "python", "js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript", "ts": "typescript", "mts": "typescript", "cts": "typescript", "tsx": "typescript", "html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml", "xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml", "rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml", "svg": "xml", "axml": "xml", "vue": "xml", "svelte": "xml", "astro": "xml", "css": "css", "scss": "scss", "sass": "scss", "less": "less", "json": "json", "json5": "json", "jsonc": "json", "geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json", "yaml": "yaml", "yml": "yaml", "toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini", "properties": "ini", "prop": "ini", "inf": "ini", "reg": "ini", "url": "ini", "gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini", "ninja": "ini", "md": "markdown", "markdown": "markdown", "mkd": "markdown", "mdown": "markdown", "mdwn": "markdown", "rmd": "markdown", "qmd": "markdown", "tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex", "dtx": "latex", "sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql", "mssql": "sql", "sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash", "mksh": "bash", "yash": "bash", "fish": "shell", "ps1": "powershell", "psm1": "powershell", "psd1": "powershell", "bat": "dos", "cmd": "dos", "rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby", "go": "go", "rs": "rust", "java": "java", "jsp": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala", "sc": "scala", "groovy": "groovy", "gvy": "groovy", "gradle": "groovy", "c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp", "hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp", "ino": "cpp", "pde": "cpp", "cs": "csharp", "csx": "csharp", "cake": "csharp", "php": "php", "phtml": "php-template", "swift": "swift", "r": "r", "jl": "julia", "lua": "lua", "dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl", "hs": "haskell", "lhs": "haskell", "ex": "elixir", "exs": "elixir", "eex": "elixir", "heex": "elixir", "leex": "elixir", "erl": "erlang", "hrl": "erlang", "escript": "erlang", "clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure", "ml": "ocaml", "mli": "ocaml", "fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp", "nim": "nim", "nims": "nim", "d": "d", "di": "d", "m": "objectivec", "mm": "objectivec", "vb": "vbnet", "vbs": "vbnet", "bas": "vbnet", "pas": "delphi", "pp": "delphi", "dpr": "delphi", "vhd": "vhdl", "vhdl": "vhdl", "v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog", "asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm", "ada": "ada", "ads": "ada", "adb": "ada", "f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran", "f03": "fortran", "f08": "fortran", "cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext", "dockerfile": "dockerfile", "tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext", "cmake": "cmake", "makefile": "makefile", "mk": "makefile", "mak": "makefile", "gql": "graphql", "graphql": "graphql", "graphqls": "graphql", "proto": "protobuf", "thrift": "thrift", "vim": "vim", "vimrc": "vim", "gvimrc": "vim", "awk": "awk", "gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode", "http": "http", "rest": "http", "nginx": "nginx", "apache": "apache", "htaccess": "apache", "wat": "wasm", "diff": "diff", "patch": "diff", "rej": "diff", "bib": "plaintext", "bbl": "plaintext", "txt": "plaintext", "log": "plaintext", "text": "plaintext", "csv": "plaintext", "tsv": "plaintext", "tab": "plaintext", "nfo": "plaintext", "asc": "plaintext", "lock": "plaintext", "gitignore": "plaintext", "gitattributes": "plaintext", "gitkeep": "plaintext", "dockerignore": "plaintext", "npmignore": "plaintext", } # Aliases users may type in markdown fences / old block labels. _CODE_ALIASES = { "py": "python", "js": "javascript", "jsx": "javascript", "ts": "typescript", "tsx": "typescript", "html": "xml", "htm": "xml", "markup": "xml", "yml": "yaml", "md": "markdown", "sh": "bash", "zsh": "bash", "console": "shell", "ps1": "powershell", "ps": "powershell", "bat": "dos", "cmd": "dos", "batch": "dos", "c++": "cpp", "c#": "csharp", "cs": "csharp", "kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl", "toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini", "tex": "latex", "docker": "dockerfile", "make": "makefile", "plain text": "plaintext", "plain": "plaintext", "text": "plaintext", "none": "plaintext", "": "plaintext", } # highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js. _HLJS_LANGS = frozenset({ "ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp", "csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos", "elixir", "erlang", "fsharp", "fortran", "gcode", "go", "graphql", "groovy", "haskell", "http", "ini", "java", "javascript", "json", "julia", "kotlin", "latex", "less", "lua", "makefile", "markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl", "php", "php-template", "plaintext", "powershell", "protobuf", "python", "python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql", "stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog", "vhdl", "vim", "wasm", "x86asm", "xml", "yaml", }) def normalize_code_lang(lang: str | None) -> str: """Normalize any code language label to a bundled highlight.js id. Accepts highlight.js ids, file extensions, markdown fence names and the legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``. """ lang = (lang or "").strip().lower() if not lang or lang in ("plain text", "none"): return "plaintext" if lang in _CODE_ALIASES: lang = _CODE_ALIASES[lang] if lang in _CODE_LANG.values() or lang in _HLJS_LANGS: return lang return "plaintext" def code_lang_for_file(filename: str | None) -> str: """Best highlight.js id for an uploaded / repo file name.""" name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() if not name: return "plaintext" if name in _FILENAME_LANG: return _FILENAME_LANG[name] ext = name.rsplit(".", 1)[-1] if "." in name else "" return _CODE_LANG.get(ext, "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(settings.data_dir) 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 name in _TEXTUAL_FILENAMES 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 return "code", (text, code_lang_for_file(name)) # 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)}" 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("" + "".join(head_cells) + "") head = "" + "".join(head_rows) + "" tbody_rows = rows[header_rows:] body_rows = [] for r in tbody_rows: cells = list(r) + [""] * (width - len(r)) row_cells = [] for ci, c in enumerate(cells): tag = "th" if first_col and ci == 0 else "td" row_cells.append(cell_html(tag, c, align[ci])) body_rows.append("" + "".join(row_cells) + "") body = "" + "".join(body_rows) + "" return f'{head}{body}
' # ── Markdown renderer (raw markdown → exportable fragments) ── def _md_to_blocks(md: str) -> list: """Convert raw Markdown text into the same lightweight block list the editor produces (headings, lists, to-do, quote, code, divider, paragraph). Kept intentionally simple: inline formatting (bold/links) is preserved as literal text, matching how the block editor treats imported .md files. """ blocks: list = [] buf = md.replace("\r\n", "\n").replace("\r", "\n") lines = buf.split("\n") i = 0 n = len(lines) para: list[str] = [] def flush_para(): nonlocal para if para: blocks.append({"type": "paragraph", "content": "\n".join(para).strip()}) para = [] while i < n: line = lines[i].rstrip() stripped = line.strip() if not stripped: flush_para() i += 1 continue if stripped.startswith("```") or stripped.startswith("~~~"): flush_para() fence = stripped[0:3] lang = stripped[3:].strip() i += 1 code: list[str] = [] while i < n and not lines[i].strip().startswith(fence): code.append(lines[i]) i += 1 if i < n: i += 1 # closing fence blocks.append({"type": "code", "content": "\n".join(code), "language": lang}) continue if stripped.startswith("|"): # GFM pipe table: header row immediately followed by a delimiter row parsed = _parse_table_at(lines, i, n) if parsed is not None: flush_para() tbl, i = parsed blocks.append(tbl) continue m = re.match(r"^(#{1,6})\s+(.*)$", stripped) if m and line == stripped: # ATX heading must be whole line level = len(m.group(1)) flush_para() blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()}) i += 1 continue if stripped == "---" or stripped == "***" or stripped == "___": flush_para() blocks.append({"type": "divider", "content": ""}) i += 1 continue if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s) if not m2: break blocks.append({ "type": "to_do", "content": m2.group(2).strip(), "checked": m2.group(1).lower() == "x", }) i += 1 continue if re.match(r"^\s*[-*+]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^[-*+]\s+(.*)$", s) if not m2: break blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()}) i += 1 continue if re.match(r"^\s*\d+[.)]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^\d+[.)]\s+(.*)$", s) if not m2: break blocks.append({"type": "numbered_list", "content": m2.group(1).strip()}) i += 1 continue mq = re.match(r"^>\s?(.*)$", stripped) if mq and line == stripped: flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^>\s?(.*)$", s) if not m2: break para.append(m2.group(1)) i += 1 blocks.append({"type": "quote", "content": "\n".join(para)}) para = [] continue para.append(stripped) i += 1 flush_para() return blocks def _page_blocks(page: dict) -> list: """Blocks used for HTML/PDF rendering regardless of storage format.""" kind, payload = _page_source(page) if kind == "blocks": return payload if kind == "code": text, lang = payload return [{"type": "code", "content": text, "language": lang}] if text else [] if kind == "md": return _md_to_blocks(payload) return [] def _page_markdown_source(page: dict) -> str: """Raw markdown when the page IS markdown-sourced, else empty string.""" kind, payload = _page_source(page) if kind == "md": return payload return "" def markdown_to_blocks(md: str) -> list: """Public wrapper around the GFM→blocks parser (used by page import).""" return _md_to_blocks(md) # ═══════════════ 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"```{normalize_code_lang(lang) if lang else ''}\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})") elif t == "video": out.append(f"[Video]({b.get('src') or ''})") elif t == "audio": out.append(f"[Audio]({b.get('src') or ''})") elif t == "bookmark": url = b.get("url") or b.get("src") or "" title = (b.get("title") or "").strip() out.append(f"[{title or url}]({url})" if title else url) elif t == "embed": url = b.get("src") or "" if b.get("embed_type") in ("pdf", "download", None, ""): out.append(f"[{url}]({url})" if url else "[embed]") else: out.append(f"[{url}]({url})" if url else "[embed]") elif t == "table": out.append(_table_to_markdown(b)) elif t == "synced": synced_id = b.get("synced_id") if synced_id: try: from app.services.synced_blocks import get_synced_block sb = get_synced_block(synced_id) if sb and sb.get("content"): resolved = json.loads(sb["content"]) if isinstance(resolved, list): out.append(blocks_to_markdown(resolved)) else: out.append(str(resolved)) except Exception: out.append(f"[Synced block {synced_id}]") 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)}", ""] md_source = _page_markdown_source(page) if md_source: parts.append(md_source.strip()) else: md = blocks_to_markdown(_page_blocks(page)) 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 "" hljs_lang = normalize_code_lang(lang) 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 in ("mermaid", "equation_inline", "progress"): # v7.3.0 blocks — server-rendered so export embeds real content from app.services.wiki_blocks import render_block parts.append(render_block(b)) elif t == "image": src = b.get("src") or "" alt = _text(b.get("alt")) parts.append(f'
    {alt}
    {alt}
    ') elif t == "video": src = b.get("src") or "" if src: parts.append(f'') elif t == "audio": src = b.get("src") or "" if src: parts.append(f'') elif t == "bookmark": url = b.get("url") or b.get("src") or "" title = _text(b.get("title")) or url desc = _text(b.get("description")) img = b.get("image") or "" site = _text(b.get("site_name")) or "" img_html = f'' if img else "" desc_html = f'
    {desc}
    ' if desc else "" site_html = f'
    {site}
    ' if site else "" parts.append( f'' f'
    ' f'
    {title}
    ' f'{desc_html}{site_html}
    {img_html}
    ' ) elif t == "embed": url = b.get("src") or "" emb = (b.get("embed_type") or "") if emb in ("inline_dbs", "collection"): parts.append('
    [Embedded content]
    ') elif emb == "download": parts.append(f'⬇ {_text(b.get("file_name") or "Download")}') elif emb == "pdf" and url: parts.append(f'') elif url: from app.services.embeds import embed_src src = b.get("embed_src") or embed_src(url) or url height = 520 if b.get("height"): try: height = int(b["height"]) except (ValueError, TypeError): pass parts.append( f'
    ' ) elif t == "table": parts.append(_table_to_html(b)) elif t == "synced": synced_id = b.get("synced_id") if synced_id: try: from app.services.synced_blocks import get_synced_block sb = get_synced_block(synced_id) if sb and sb.get("content"): resolved = json.loads(sb["content"]) if isinstance(resolved, list): parts.append(blocks_to_html(resolved)) else: 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
    \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) body = blocks_to_html(_page_blocks(page)) return f"""{_text(title)}

    {_text(title)}

    {body} """ def page_to_pdf_bytes(page: dict) -> bytes: """Render a page to a PDF. Primary engine: WeasyPrint — renders colour emoji and proper CSS tables (needs system libs: pango + fonts; available in the Docker image). Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are absent (e.g. a Windows dev host) — text/table content still exports, though emoji are limited to monochrome by the engine. """ # 1) WeasyPrint (best fidelity: colour emoji, CSS tables) try: from weasyprint import HTML html = page_to_standalone_html(page, include_children=False) return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf() except Exception: # ImportError or missing native libs (OSError) -> fallback pass # 2) xhtml2pdf fallback (pure Python) 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) 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()