"""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)}{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