Files
flowdeck/app/services/export.py
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00

1094 lines
44 KiB
Python

"""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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
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("<tr>" + "".join(head_cells) + "</tr>")
head = "<thead>" + "".join(head_rows) + "</thead>"
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("<tr>" + "".join(row_cells) + "</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>"
return f'<table class="ftable">{head}{body}</table>'
# ── 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'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
elif t == "heading_2":
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
elif t == "heading_3":
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
elif t == "heading_4":
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
elif t == "bulleted_list":
parts.append(f"<li>{c}</li>")
elif t == "numbered_list":
parts.append(f"<li>{c}</li>")
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'<div class="todo"><input type="checkbox" {checked} disabled>'
f'<span style="{style}">{c}</span></div>'
)
elif t == "toggle":
children = blocks_to_html(b.get("children") or [])
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
elif t == "quote":
parts.append(f"<blockquote>{c}</blockquote>")
elif t == "divider":
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
hljs_lang = normalize_code_lang(lang)
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
)
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
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'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
for x in toc
)
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
elif t == "columns":
cols = "".join(
f'<div class="column">{blocks_to_html([child])}</div>'
for child in (b.get("children") or [])
)
parts.append(f'<div class="columns">{cols}</div>')
elif t == "callout":
icon = b.get("icon") or "💡"
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
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'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
elif t == "video":
src = b.get("src") or ""
if src:
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
elif t == "audio":
src = b.get("src") or ""
if src:
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
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'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
parts.append(
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src") or ""
emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"):
parts.append('<div class="embed-note">[Embedded content]</div>')
elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url:
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
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'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
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"<p>{_text(resolved)}</p>")
except Exception:
parts.append(f"<p>[Synced block {synced_id}]</p>")
else:
parts.append(f"<p>{c}</p>")
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"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
sub_html = ""
if include_children:
for sub in _child_pages(page):
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
sub_html += "</div>"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_text(title)}</title>
<style>{_standalone_css()}</style>
</head>
<body>
<div class="wrap">
<h1>{_text(title)}</h1>
{body}
{sub_html}
{footer}
</div>
</body>
</html>"""
# ═══════════════ 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"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
<style>
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
h1{{font-size:26px;margin:0 0 10px;}}
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
h3{{font-size:16px;margin:18px 0 6px;}}
h4{{font-size:14px;margin:14px 0 4px;}}
p,li{{margin:4px 0;}}
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
code{{font-family:monospace;font-size:10px;}}
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
table{{border-collapse:collapse;width:100%;}}
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
.ftable th{{background:#f0f0f0;font-weight:bold;}}
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
.todo{{margin:4px 0;}}
.math{{font-style:italic;margin:10px 0;}}
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
</style></head><body>
<h1>{_text(title)}</h1>
{body}
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
</body></html>"""
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'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
items = "".join(link(p) for p in pages)
return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>FlowDeck Site</title>
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
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()