fix(export): v4.7.2 contenu des documents absent des exports MD/HTML/PDF
Le service d'export ne lisait que les pages content_format='blocks'. Les docs stockees autrement sortaient avec le seul titre: - content_format='file' (.md/code uploades): content=JSON meta, le vrai texte est sur disque (/data/uploads/workspace_*) -> n'etait jamais lu. - content_format='markdown': HTML/PDF enveloppait chaque ligne en <p> (headings et listes aplatis). Resolution de la vraie source pour les 3 formats (app/services/export.py): - lit le fichier upload sur disque pour les pages file (repertoire via FLOWDECK_DATA_DIR, defaut /data), - rend les pages markdown / fichiers .md en blocs (headings, listes, code, quote, todo) pour un HTML/PDF riche, - fichiers texte non-markdown -> bloc de code, - binaires (PDF/images) ignores. 195/195 tests (5 nouveaux v4.7.2). Verifie en reel via HTTP sur README.md et l'arborescence Base de Connaissances (contenu complet dans les 3 formats).
This commit is contained in:
@@ -1,5 +1,18 @@
|
|||||||
# Changelog — FlowDeck
|
# Changelog — FlowDeck
|
||||||
|
|
||||||
|
## v4.7.2 (2026-09-03) — Fix export : le contenu des documents était absent
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Export Markdown / HTML / PDF : seuls le nom de page et le titre étaient inclus, jamais le contenu.** Cause : le service d'export ne lisait que les pages en `content_format='blocks'`. Or les documents peuvent être stockés autrement :
|
||||||
|
- `content_format='file'` (fichiers `.md`/code uploadés) : le `content` de la page n'est que des métadonnées JSON — le vrai texte est un fichier sur disque (`/data/uploads/workspace_*`). L'export n'allait jamais le lire → uniquement le titre.
|
||||||
|
- `content_format='markdown'` : le HTML/PDF se contentait d'envelopper chaque ligne en `<p>` (titres et listes aplatis).
|
||||||
|
- Le service résout désormais la vraie source pour les 3 formats : lit le fichier sur disque pour les pages `file`, rend les pages `markdown`/fichiers `.md` en blocs (headings, listes, code…), et traite les fichiers texte non-markdown comme bloc de code. Les fichiers binaires (PDF/images) sont laissés de côté (rien d'exploitable).
|
||||||
|
- Chemin des uploads rendu configurable pour les tests via `FLOWDECK_DATA_DIR` (défaut `/data`, identique à `dashboard.py`).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- **195 tests au total** (5 nouveaux v4.7.2) : page markdown → corps exporté ; page markdown → HTML avec headings/listes ; fichier `.md` uploadé → contenu réel dans Markdown/HTML/PDF ; fichier code → exporté ; fichier binaire → non déversé.
|
||||||
|
- Vérifié en réel sur `/data` (page `README.md` et arborescence « Base de Connaissances » → fichiers `.md`) : le corps complet est maintenant présent dans les 3 formats.
|
||||||
|
|
||||||
## v4.7.1 (2026-09-03) — Fix UI éditeur : menus Share / More invisibles
|
## v4.7.1 (2026-09-03) — Fix UI éditeur : menus Share / More invisibles
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+5
-1
@@ -291,6 +291,10 @@ Détails livrés :
|
|||||||
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
|
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
|
||||||
- [x] Vérifié Playwright headless + 190/190 tests
|
- [x] Vérifié Playwright headless + 190/190 tests
|
||||||
|
|
||||||
|
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
|
||||||
|
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
|
||||||
|
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
|
||||||
|
|
||||||
### v4.8.0 — Collaboration
|
### v4.8.0 — Collaboration
|
||||||
- [ ] **Inline comments** — commentaires sur sélection de texte
|
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||||
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
||||||
@@ -443,4 +447,4 @@ v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅
|
|||||||
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
|
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
|
||||||
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML Agent IA (futur)
|
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML Agent IA (futur)
|
||||||
|
|
||||||
*Dernière mise à jour: 2026-09-03 — v4.7.1 Fix UI Share/More/Export ✅*
|
*Dernière mise à jour: 2026-09-03 — v4.7.2 Export fix contenu ✅*
|
||||||
|
|||||||
+2
-2
@@ -39,13 +39,13 @@ async def lifespan(_app: FastAPI):
|
|||||||
(admin_hash,)
|
(admin_hash,)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("FlowDeck v4.7.1 started on port %d", settings.app_port)
|
logger.info("FlowDeck v4.7.2 started on port %d", settings.app_port)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.7.1",
|
version="4.7.2",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
+253
-16
@@ -1,16 +1,26 @@
|
|||||||
"""FlowDeck — Export service (v4.7.0).
|
"""FlowDeck — Export service (v4.7.2).
|
||||||
|
|
||||||
Four types of export, all generated server-side:
|
Four types of export, all generated server-side:
|
||||||
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
|
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
|
||||||
- HTML (``page_to_standalone_html``): document autonome (styles inline)
|
- HTML (``page_to_standalone_html``): document autonome (styles inline)
|
||||||
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
|
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
|
||||||
- Site (``build_static_site``): site statique multi-pages (zip)
|
- 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -54,6 +64,242 @@ def _block_text(b: dict) -> str:
|
|||||||
return _text(b.get("content"), escape=False)
|
return _text(b.get("content"), escape=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Source resolution: read real textual content for ANY page type ──
|
||||||
|
|
||||||
|
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||||
|
_TEXTUAL_EXTS = {
|
||||||
|
"md", "markdown", "txt", "log", "text",
|
||||||
|
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||||
|
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||||
|
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||||
|
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||||
|
"properties", "gitignore", "dockerfile", "makefile",
|
||||||
|
}
|
||||||
|
_CODE_LANG = {
|
||||||
|
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||||
|
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||||
|
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||||
|
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||||
|
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||||
|
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||||
|
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||||
|
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||||
|
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||||
|
"astro": "html", "properties": "ini", "md": "markdown",
|
||||||
|
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||||
|
"text": "plaintext",
|
||||||
|
}
|
||||||
|
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_root() -> Path:
|
||||||
|
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||||
|
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||||
|
|
||||||
|
|
||||||
|
def _file_meta(page: dict) -> dict:
|
||||||
|
try:
|
||||||
|
meta = json.loads(page.get("content") or "{}")
|
||||||
|
return meta if isinstance(meta, dict) else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _file_text(page: dict) -> str | None:
|
||||||
|
"""Return the textual content of an uploaded ``file`` page, or None.
|
||||||
|
|
||||||
|
Only reads plain-text / code / markdown files. Binary (PDF, images…)
|
||||||
|
returns None and is skipped by exporters (nothing meaningful to include).
|
||||||
|
"""
|
||||||
|
if (page.get("content_format") or "") != "file":
|
||||||
|
return None
|
||||||
|
meta = _file_meta(page)
|
||||||
|
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||||
|
if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"):
|
||||||
|
return None
|
||||||
|
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||||
|
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||||
|
mime = (meta.get("mime_type") or "").lower()
|
||||||
|
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
full = (_data_root() / rel).resolve()
|
||||||
|
root = _data_root().resolve()
|
||||||
|
if root not in full.parents:
|
||||||
|
return None
|
||||||
|
return full.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _page_source(page: dict):
|
||||||
|
"""Return (kind, payload) describing where the page's real content lives.
|
||||||
|
|
||||||
|
kind ∈ {"blocks", "md", "code"}:
|
||||||
|
- "blocks": payload is the block list (block editor pages)
|
||||||
|
- "md" : payload is raw Markdown text
|
||||||
|
- "code" : payload is (text, language)
|
||||||
|
An empty/unsupported page yields ("blocks", []).
|
||||||
|
"""
|
||||||
|
fmt = (page.get("content_format") or "blocks")
|
||||||
|
content = page.get("content") or ""
|
||||||
|
|
||||||
|
if fmt == "blocks":
|
||||||
|
return "blocks", _blocks_of(page)
|
||||||
|
|
||||||
|
if fmt == "markdown":
|
||||||
|
if content.strip():
|
||||||
|
return "md", content
|
||||||
|
return "blocks", []
|
||||||
|
|
||||||
|
if fmt == "file":
|
||||||
|
text = _file_text(page)
|
||||||
|
if text is None:
|
||||||
|
return "blocks", []
|
||||||
|
meta = _file_meta(page)
|
||||||
|
name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||||
|
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||||
|
mime = (meta.get("mime_type") or "").lower()
|
||||||
|
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||||
|
return "md", text
|
||||||
|
lang = _CODE_LANG.get(ext, "plaintext")
|
||||||
|
return "code", (text, lang)
|
||||||
|
|
||||||
|
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||||
|
if content.strip():
|
||||||
|
return "md", content
|
||||||
|
return "blocks", []
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
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+", 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
|
||||||
|
mtodo = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", stripped)
|
||||||
|
if mtodo:
|
||||||
|
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
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════ Markdown ═══════════════
|
# ═══════════════ Markdown ═══════════════
|
||||||
|
|
||||||
def blocks_to_markdown(blocks: list) -> str:
|
def blocks_to_markdown(blocks: list) -> str:
|
||||||
@@ -117,11 +363,11 @@ def _child_pages(page: dict) -> list:
|
|||||||
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
|
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
|
||||||
"""Markdown for a single page, with optional sub-pages appended."""
|
"""Markdown for a single page, with optional sub-pages appended."""
|
||||||
parts = [f"# {_page_title(page)}", ""]
|
parts = [f"# {_page_title(page)}", ""]
|
||||||
content_inner = _blocks_of(page)
|
md_source = _page_markdown_source(page)
|
||||||
if page.get("content_format") == "markdown" and (page.get("content") or "").strip():
|
if md_source:
|
||||||
parts.append((page["content"] or "").strip())
|
parts.append(md_source.strip())
|
||||||
else:
|
else:
|
||||||
md = blocks_to_markdown(content_inner)
|
md = blocks_to_markdown(_page_blocks(page))
|
||||||
if md:
|
if md:
|
||||||
parts.append(md)
|
parts.append(md)
|
||||||
md = "\n\n".join(filter(None, parts)).rstrip()
|
md = "\n\n".join(filter(None, parts)).rstrip()
|
||||||
@@ -253,11 +499,7 @@ def page_to_standalone_html(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Return a standalone, self-contained HTML document for a page."""
|
"""Return a standalone, self-contained HTML document for a page."""
|
||||||
title = _page_title(page)
|
title = _page_title(page)
|
||||||
content_inner = _blocks_of(page)
|
body = blocks_to_html(_page_blocks(page))
|
||||||
if page.get("content_format") == "markdown" and (page.get("content") or "").strip():
|
|
||||||
body = "\n".join(f"<p>{_text(line)}</p>" for line in (page.get("content") or "").splitlines())
|
|
||||||
else:
|
|
||||||
body = blocks_to_html(content_inner)
|
|
||||||
|
|
||||||
meta_updated = page.get("updated_at") or ""
|
meta_updated = page.get("updated_at") or ""
|
||||||
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
||||||
@@ -293,11 +535,7 @@ def page_to_standalone_html(
|
|||||||
def _pdf_html(page: dict) -> str:
|
def _pdf_html(page: dict) -> str:
|
||||||
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
||||||
title = _page_title(page)
|
title = _page_title(page)
|
||||||
content_inner = _blocks_of(page)
|
body = blocks_to_html(_page_blocks(page))
|
||||||
if page.get("content_format") == "markdown" and (page.get("content") or "").strip():
|
|
||||||
body = "\n".join(f"<p>{_text(line)}</p>" for line in (page.get("content") or "").splitlines())
|
|
||||||
else:
|
|
||||||
body = blocks_to_html(content_inner)
|
|
||||||
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
||||||
<style>
|
<style>
|
||||||
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
||||||
@@ -339,7 +577,6 @@ def _site_index_html(pages: list[dict]) -> str:
|
|||||||
"""Build the index.html of the static site (list of all pages)."""
|
"""Build the index.html of the static site (list of all pages)."""
|
||||||
def link(p: dict) -> str:
|
def link(p: dict) -> str:
|
||||||
title = _page_title(p)
|
title = _page_title(p)
|
||||||
slug = quote(f"{title}", safe="")
|
|
||||||
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
||||||
|
|
||||||
items = "".join(link(p) for p in pages)
|
items = "".join(link(p) for p in pages)
|
||||||
|
|||||||
+143
-1
@@ -3024,4 +3024,146 @@ def test_v470_export_site_zip(client):
|
|||||||
def test_v470_export_404(client):
|
def test_v470_export_404(client):
|
||||||
"""Export endpoints return 404 for missing pages."""
|
"""Export endpoints return 404 for missing pages."""
|
||||||
resp = client.get("/api/export/markdown/999999")
|
resp = client.get("/api/export/markdown/999999")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.7.2: Export resolves file & markdown page content (not just title) ──
|
||||||
|
# Tested at the service level: the export HTTP endpoints run behind an in-memory
|
||||||
|
# rate limiter whose store persists across the whole test process, so dozens of
|
||||||
|
# additional HTTP hits at the end of the suite trip 429. These assert on the
|
||||||
|
# service functions directly, which is where the content-resolution lives.
|
||||||
|
|
||||||
|
def _make_src_page(raw_md=None, file_info=None, title="Src Page", parent_id=None):
|
||||||
|
"""Insert a user + page storing raw markdown OR file metadata.
|
||||||
|
|
||||||
|
file_info = (rel_path_under_data_dir, mime). The real file is written to a
|
||||||
|
temp data dir whose path is exposed through ``FLOWDECK_DATA_DIR``.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import json as _json
|
||||||
|
from app.db import get_conn
|
||||||
|
login = "v472_" + uuid.uuid4().hex[:10]
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?, 'V472', ?)",
|
||||||
|
(login, login + "@t.com"))
|
||||||
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||||
|
if file_info:
|
||||||
|
rel, mime = file_info
|
||||||
|
payload = _json.dumps({"file_path": rel, "mime_type": mime, "size": 100})
|
||||||
|
fmt = "file"
|
||||||
|
else:
|
||||||
|
payload = raw_md
|
||||||
|
fmt = "markdown"
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO pages (workspace, title, content, content_format, parent_id) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(login, title, payload, fmt, parent_id),
|
||||||
|
)
|
||||||
|
pid = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return pid, uid
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_src(pid, uid):
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pages WHERE parent_id=? OR id=?", (pid, pid))
|
||||||
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _load(pid):
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
return dict(conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def test_v472_markdown_sourced_page_exports_body(client):
|
||||||
|
"""content_format='markdown' page exports its body, not just the title."""
|
||||||
|
from app.services.export import page_to_markdown
|
||||||
|
raw = "# Intro\n\nCeci est le contenu réel de la page.\n\n- point un\n- point deux\n"
|
||||||
|
pid, uid = _make_src_page(raw_md=raw, title="Page MD")
|
||||||
|
try:
|
||||||
|
md = page_to_markdown(_load(pid))
|
||||||
|
assert "# Page MD" in md
|
||||||
|
assert "Ceci est le contenu réel de la page." in md
|
||||||
|
assert "point un" in md
|
||||||
|
finally:
|
||||||
|
_cleanup_src(pid, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v472_markdown_sourced_page_renders_headings_to_html(client):
|
||||||
|
"""Raw-markdown page renders headings/lists in HTML (not line-wrapped)."""
|
||||||
|
from app.services.export import page_to_standalone_html
|
||||||
|
raw = "# Titre Principal\n\nParagraphe de contenu.\n\n## Sous section\n\n- a\n- b\n"
|
||||||
|
pid, uid = _make_src_page(raw_md=raw, title="Page HTML")
|
||||||
|
try:
|
||||||
|
body = page_to_standalone_html(_load(pid), include_children=False)
|
||||||
|
assert "<h1" in body and "Titre Principal" in body
|
||||||
|
assert "<h2" in body and "Sous section" in body
|
||||||
|
assert "<li>" in body and "Paragraphe de contenu" in body
|
||||||
|
finally:
|
||||||
|
_cleanup_src(pid, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v472_file_page_exports_uploaded_content(client, monkeypatch, tmp_path):
|
||||||
|
"""Uploaded markdown file page exports its real disk content (v4.7.2 fix)."""
|
||||||
|
from app.services.export import page_to_markdown, page_to_standalone_html, page_to_pdf_bytes
|
||||||
|
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||||
|
rel = "uploads/workspace_1/note.md"
|
||||||
|
disk = tmp_path / rel
|
||||||
|
disk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
disk.write_text("# Note technique\n\ncontenu du fichier sur disque\n", encoding="utf-8")
|
||||||
|
|
||||||
|
pid, uid = _make_src_page(file_info=(rel, "text/markdown"), title="note.md")
|
||||||
|
try:
|
||||||
|
md = page_to_markdown(_load(pid), include_children=False)
|
||||||
|
assert "# note.md" in md
|
||||||
|
assert "Note technique" in md
|
||||||
|
assert "contenu du fichier sur disque" in md
|
||||||
|
|
||||||
|
html = page_to_standalone_html(_load(pid), include_children=False)
|
||||||
|
assert "Note technique" in html
|
||||||
|
assert "contenu du fichier sur disque" in html
|
||||||
|
|
||||||
|
pdf = page_to_pdf_bytes(_load(pid))
|
||||||
|
assert pdf[:5] == b"%PDF-"
|
||||||
|
finally:
|
||||||
|
_cleanup_src(pid, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v472_code_file_page_exported_as_code(client, monkeypatch, tmp_path):
|
||||||
|
"""A non-markdown text file page exports its content, not blank."""
|
||||||
|
from app.services.export import page_to_markdown, page_to_standalone_html
|
||||||
|
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||||
|
rel = "uploads/workspace_1/app.py"
|
||||||
|
disk = tmp_path / rel
|
||||||
|
disk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
disk.write_text("def hello():\n return 'world'\n", encoding="utf-8")
|
||||||
|
|
||||||
|
pid, uid = _make_src_page(file_info=(rel, "text/x-python"), title="app.py")
|
||||||
|
try:
|
||||||
|
md = page_to_markdown(_load(pid), include_children=False)
|
||||||
|
assert "def hello():" in md and "world" in md
|
||||||
|
html = page_to_standalone_html(_load(pid), include_children=False)
|
||||||
|
assert "def hello():" in html
|
||||||
|
finally:
|
||||||
|
_cleanup_src(pid, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v472_binary_file_page_not_exported(client, monkeypatch, tmp_path):
|
||||||
|
"""Non-textual files (e.g. PDF uploads) export only the title, no garbage."""
|
||||||
|
from app.services.export import page_to_markdown
|
||||||
|
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||||
|
rel = "uploads/workspace_1/manual.pdf"
|
||||||
|
disk = tmp_path / rel
|
||||||
|
disk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
disk.write_bytes(b"%PDF-1.4\nfake binary content")
|
||||||
|
|
||||||
|
pid, uid = _make_src_page(file_info=(rel, "application/pdf"), title="manual.pdf")
|
||||||
|
try:
|
||||||
|
md = page_to_markdown(_load(pid), include_children=False)
|
||||||
|
assert "# manual.pdf" in md
|
||||||
|
assert "fake binary" not in md # never dump binary into markdown
|
||||||
|
finally:
|
||||||
|
_cleanup_src(pid, uid)
|
||||||
Reference in New Issue
Block a user