feat(export): v2.1.0 - export multi-formats HTML/MD-bundle/ePub
- backend/export.py: 3 exporters (HTML standalone, ZIP MD, ePub 3)
- HTML: CSS inliné, images base64, navigation wikilinks
- MD bundle: ZIP préserve structure répertoire
- ePub: zipfile + mistune, zero nouvelle dépendance
- 3 endpoints API: /api/export/{html,md-bundle,epub}
- UI: dropdown Export dans toolbar viewer (HTML/MD/ePub)
- 20 tests (validité, contenu, erreurs, API)
- 439 tests passent au total
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Export utilities for ObsiGate — standalone HTML, Markdown bundle (.zip) and ePub.
|
||||
|
||||
Each function converts vault content (markdown notes) into a self-contained
|
||||
downloadable artifact. Rendering reuses ``mistune`` (already a core dependency)
|
||||
so no extra markdown engine is required. The ePub generator builds a minimal
|
||||
but valid EPUB 3 container by hand (``zipfile`` only) so there is no hard
|
||||
dependency on ``ebooklib``.
|
||||
|
||||
All functions raise :class:`ExportError` on any failure (missing file, binary
|
||||
content, unreadable source, ...) so callers can translate to HTTP errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import html as html_mod
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
|
||||
logger = logging.getLogger("obsigate.export")
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""Raised when an export operation cannot be completed."""
|
||||
|
||||
|
||||
# Cached mistune renderer (singleton) — mirrors backend.main's configuration.
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
# File extensions considered "safe" text files for markdown-like exports.
|
||||
_MARKDOWN_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".txt"}
|
||||
|
||||
_HTML_CSS = """
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 760px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a2e;
|
||||
font-size: 16px;
|
||||
}
|
||||
h1 { font-size: 28px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
|
||||
h2 { font-size: 22px; margin-top: 28px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
|
||||
h3 { font-size: 18px; margin-top: 24px; }
|
||||
h4, h5, h6 { font-size: 16px; margin-top: 20px; }
|
||||
p { margin: 10px 0; }
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code { font-size: 14px; background: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
a { color: #4f46e5; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; border-radius: 4px; }
|
||||
blockquote { border-left: 3px solid #ccc; padding-left: 14px; color: #555; margin: 12px 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 14px; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
ul, ol { margin: 8px 0; padding-left: 24px; }
|
||||
li { margin: 2px 0; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 20px 0; }
|
||||
nav.export-nav {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin: 16px 0 28px;
|
||||
background: #fafafa;
|
||||
}
|
||||
nav.export-nav h2 { border: none; margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
nav.export-nav ul { margin: 0; padding-left: 20px; }
|
||||
.wikilink-missing { color: #c0392b; }
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""URL-safe slug from arbitrary text (Unicode-aware)."""
|
||||
text = html_mod.unescape(re.sub(r"<[^>]+>", "", text))
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
if unicodedata.category(ch).startswith(("L", "N")) or ch in (" ", "-"):
|
||||
cleaned.append(ch)
|
||||
text = re.sub(r"\s+", "-", "".join(cleaned))
|
||||
text = re.sub(r"-+", "-", text)
|
||||
return text.strip("-") or "document"
|
||||
|
||||
|
||||
def _resolve(vault_path: Path, file_path: Path | str) -> Path:
|
||||
"""Resolve *file_path* against *vault_path*, accepting absolute or relative.
|
||||
|
||||
Returns an absolute :class:`Path`. Does not require the file to exist.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_absolute():
|
||||
return file_path
|
||||
return (Path(vault_path) / file_path).resolve()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
"""Read a text file, raising :class:`ExportError` on missing/binary content."""
|
||||
if not path.exists() or not path.is_file():
|
||||
raise ExportError(f"File not found: {path}")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot read file {path}: {e}") from e
|
||||
if b"\x00" in raw[:4096]:
|
||||
raise ExportError(f"File appears to be binary: {path}")
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> tuple[str, dict]:
|
||||
"""Split frontmatter from a markdown document.
|
||||
|
||||
Returns ``(body, metadata)``. Falls back gracefully when no frontmatter.
|
||||
"""
|
||||
try:
|
||||
post = frontmatter.loads(text)
|
||||
return post.content, dict(post.metadata or {})
|
||||
except Exception:
|
||||
return text, {}
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name."""
|
||||
cleaned = "".join(
|
||||
c for c in name if c.isascii() and (c.isalnum() or c in " _-.")
|
||||
).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
def _collect_markdown_files(vault_path: Path) -> list[Path]:
|
||||
"""List all markdown files in the vault, sorted by relative path."""
|
||||
vault_path = Path(vault_path)
|
||||
results = []
|
||||
if not vault_path.is_dir():
|
||||
return results
|
||||
for p in sorted(vault_path.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in _MARKDOWN_EXTS:
|
||||
results.append(p)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown → HTML (shared by HTML and ePub exporters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _inline_images(md: str, file_dir: Path, vault_path: Path) -> str:
|
||||
"""Replace image references with base64 data URIs.
|
||||
|
||||
Supports standard markdown ```` and Obsidian embeds
|
||||
``![[image.png]]`` / ``![[image.png|300]]``. Sources are resolved relative
|
||||
to the note's directory, then the vault root.
|
||||
"""
|
||||
candidates = [file_dir, vault_path]
|
||||
|
||||
def _to_data_uri(src: str) -> str:
|
||||
src = src.strip().strip("<>")
|
||||
if src.startswith(("http://", "https://", "data:")):
|
||||
return src # leave remote/data URLs untouched
|
||||
src_path = None
|
||||
for base in candidates:
|
||||
candidate = (base / src).resolve()
|
||||
if candidate.is_file():
|
||||
src_path = candidate
|
||||
break
|
||||
if src_path is None:
|
||||
return src # unresolved — leave as-is
|
||||
try:
|
||||
data = src_path.read_bytes()
|
||||
mime = mimetypes.guess_type(str(src_path))[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except OSError:
|
||||
return src
|
||||
|
||||
# Obsidian embeds: ![[path]] or ![[path|size]]
|
||||
def _embed(match):
|
||||
inner = match.group(1).strip()
|
||||
target = inner.split("|", 1)[0].strip()
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[\[([^\]]+)\]\]", _embed, md)
|
||||
|
||||
# Standard markdown images
|
||||
def _img(match):
|
||||
alt = match.group(1)
|
||||
src = match.group(2)
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", _img, md)
|
||||
return md
|
||||
|
||||
|
||||
def _convert_wikilinks(md: str, vault_path: Path, current: Path) -> str:
|
||||
"""Convert ``[[Note]]`` / ``[[Note|display]]`` to cross-file HTML links."""
|
||||
md_files = {p.stem.lower(): p for p in _collect_markdown_files(vault_path)}
|
||||
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = (match.group(2) or target).strip()
|
||||
|
||||
# Same-document anchor
|
||||
if target.startswith("#"):
|
||||
return f'<a href="#{_slugify(target[1:])}">{html_mod.escape(display)}</a>'
|
||||
|
||||
# Resolve to another file — link to its exported .html sibling
|
||||
key = Path(target).stem.lower()
|
||||
if key in md_files:
|
||||
href = f"{_slugify(md_files[key].stem)}.html"
|
||||
return f'<a href="{href}">{html_mod.escape(display)}</a>'
|
||||
|
||||
return f'<span class="wikilink-missing">{html_mod.escape(display)}</span>'
|
||||
|
||||
return re.sub(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]", _replace, md)
|
||||
|
||||
|
||||
def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> str:
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
"""Build a navigation block linking to every other markdown note in the vault."""
|
||||
items = []
|
||||
for p in _collect_markdown_files(vault_path):
|
||||
if p.resolve() == Path(current).resolve():
|
||||
continue
|
||||
stem = p.stem
|
||||
items.append(f'<li><a href="{_slugify(stem)}.html">{html_mod.escape(stem)}</a></li>')
|
||||
if not items:
|
||||
return ""
|
||||
return (
|
||||
'<nav class="export-nav"><h2>Notes</h2><ul>'
|
||||
+ "".join(items)
|
||||
+ "</ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public exporters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def export_html(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a standalone HTML document.
|
||||
|
||||
Images are inlined as base64 data URIs, wikilinks become cross-file links,
|
||||
and a navigation block lists every other note in the vault.
|
||||
|
||||
Returns:
|
||||
UTF-8 encoded HTML as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
nav = _build_nav(vault_path, path)
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="ObsiGate">
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>{html_mod.escape(title)}</h1></header>
|
||||
{nav}
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
return html_doc.encode("utf-8")
|
||||
|
||||
|
||||
def export_md_bundle(vault_path: Path, directory_path: Path) -> bytes:
|
||||
"""Zip a directory (or single file) of markdown, preserving structure.
|
||||
|
||||
Returns:
|
||||
ZIP archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, directory_path)
|
||||
|
||||
if not path.exists():
|
||||
raise ExportError(f"Path not found: {path}")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if path.is_dir():
|
||||
base = path
|
||||
for p in sorted(path.rglob("*")):
|
||||
if p.is_file():
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname=arcname)
|
||||
# Avoid emitting an empty (invalid) zip for an empty directory
|
||||
if not any(p.is_file() for p in path.rglob("*")):
|
||||
zf.writestr(".empty", "")
|
||||
else:
|
||||
zf.write(path, arcname=path.name)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create bundle: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a minimal, valid EPUB 3 container.
|
||||
|
||||
Uses only ``zipfile`` + ``mistune`` (no ``ebooklib`` dependency). Images
|
||||
are inlined as base64 so the ePub is fully self-contained.
|
||||
|
||||
Returns:
|
||||
EPUB archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
author = str(metadata.get("author") or "ObsiGate")
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now().timestamp())}"
|
||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||
<head>
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{html_mod.escape(title)}</h1>
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# EPUB requires a valid XHTML-ish body; strip our HTML5-only doctype is not
|
||||
# needed since we emit XML directly above. Escape any bare ampersands in
|
||||
# text nodes outside tags is delegated to mistune's own escaping.
|
||||
content_opf = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bookid">{html_mod.escape(uid)}</dc:identifier>
|
||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||
<dc:language>fr</dc:language>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="chapter1"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
|
||||
toc_ncx = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="{html_mod.escape(uid)}"/>
|
||||
</head>
|
||||
<docTitle><text>{html_mod.escape(title)}</text></docTitle>
|
||||
<navMap>
|
||||
<navPoint id="navpoint-1" playOrder="1">
|
||||
<navLabel><text>{html_mod.escape(title)}</text></navLabel>
|
||||
<content src="chapter1.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>"""
|
||||
|
||||
container_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# `mimetype` must be the first entry, stored (uncompressed).
|
||||
zf.writestr(
|
||||
zipfile.ZipInfo("mimetype"),
|
||||
"application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED,
|
||||
)
|
||||
zf.writestr("META-INF/container.xml", container_xml)
|
||||
zf.writestr("OEBPS/content.opf", content_opf)
|
||||
zf.writestr("OEBPS/toc.ncx", toc_ncx)
|
||||
zf.writestr("OEBPS/chapter1.xhtml", xhtml)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create ePub: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
+67
-2
@@ -1,5 +1,5 @@
|
||||
/* ObsiGate — Viewer module */
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js';
|
||||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||||
@@ -13,6 +13,41 @@ import { openShareDialog } from './config.js';
|
||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
// export endpoints, showing a loading toast while the request is in flight.
|
||||
async function downloadExport(vault, path, format, fallbackName) {
|
||||
showToast(t("viewer.export_start"), "info");
|
||||
const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`;
|
||||
try {
|
||||
const headers = AuthManager.getAuthHeaders() || {};
|
||||
const res = await fetch(url, { credentials: "include", headers });
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
|
||||
throw new Error(detail || "HTTP " + res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fallbackName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
showToast(t("viewer.export_done"), "success");
|
||||
} catch (err) {
|
||||
console.error("Export error:", err);
|
||||
showToast(t("viewer.export_error") + " " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open export dropdown when clicking elsewhere.
|
||||
document.addEventListener("click", function (e) {
|
||||
if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return;
|
||||
document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; });
|
||||
});
|
||||
|
||||
// Pane-aware content area helper — delegates to PaneManager when split
|
||||
function getContentArea() {
|
||||
// Override set by PaneTabManager._renderFileInPane for cross-module rendering
|
||||
@@ -635,6 +670,36 @@ export function renderFile(data) {
|
||||
window.open(pdfUrl, "_blank");
|
||||
});
|
||||
|
||||
// Export dropdown button (HTML / MD bundle / ePub)
|
||||
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
|
||||
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
|
||||
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
|
||||
const exportOptions = [
|
||||
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
|
||||
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
|
||||
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
|
||||
];
|
||||
exportOptions.forEach((opt) => {
|
||||
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
|
||||
icon(opt.icon, 14),
|
||||
document.createTextNode(opt.label),
|
||||
]);
|
||||
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
|
||||
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
|
||||
item.addEventListener("click", () => {
|
||||
exportMenu.style.display = "none";
|
||||
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
|
||||
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
|
||||
});
|
||||
exportMenu.appendChild(item);
|
||||
});
|
||||
exportBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
||||
});
|
||||
exportWrap.appendChild(exportBtn);
|
||||
exportWrap.appendChild(exportMenu);
|
||||
|
||||
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
|
||||
editBtn.addEventListener("click", () => {
|
||||
openEditor(data.vault, data.path);
|
||||
@@ -754,7 +819,7 @@ export function renderFile(data) {
|
||||
// Assemble
|
||||
area.innerHTML = "";
|
||||
area.appendChild(breadcrumb);
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, exportWrap, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# tests/test_export.py — Tests for multi-format export (HTML / MD bundle / ePub)
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.export import (
|
||||
ExportError,
|
||||
export_epub,
|
||||
export_html,
|
||||
export_md_bundle,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_html
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportHtml:
|
||||
def test_valid_html(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md")
|
||||
assert isinstance(html, bytes)
|
||||
text = html.decode("utf-8")
|
||||
assert text.startswith("<!DOCTYPE html>")
|
||||
assert "<html" in text
|
||||
assert "</html>" in text
|
||||
assert "<style>" in text # inlined CSS
|
||||
|
||||
def test_renders_content(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "Introduction" in html
|
||||
assert "Python" in html
|
||||
|
||||
def test_uses_frontmatter_title(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "<title>Introduction à Python</title>" in html
|
||||
|
||||
def test_navigation_lists_other_notes(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "class=\"export-nav\"" in html
|
||||
# note2 should appear as a sibling link
|
||||
assert "note2.html" in html or "note2" in html
|
||||
|
||||
def test_wikilink_becomes_link(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note2.md").decode("utf-8")
|
||||
assert "href=" in html
|
||||
|
||||
def test_missing_file_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_html(vault, vault / "does_not_exist.md")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_md_bundle
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportMdBundle:
|
||||
def test_zip_contains_files(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault)
|
||||
assert isinstance(data, bytes)
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert "note1.md" in names
|
||||
assert "note2.md" in names
|
||||
assert "config.json" in names
|
||||
# Subdirectory preserved
|
||||
assert any("Projets/projet.md" in n for n in names)
|
||||
|
||||
def test_zip_preserves_structure(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault / "Projets")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert "projet.md" in names
|
||||
|
||||
def test_single_file_bundle(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
assert "note1.md" in zf.namelist()
|
||||
|
||||
def test_missing_path_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_md_bundle(vault, vault / "nope")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_epub
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportEpub:
|
||||
def test_epub_is_valid_zip(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
assert isinstance(data, bytes)
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
assert zf.testzip() is None # archive is intact
|
||||
|
||||
def test_epub_mimetype_first(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert names[0] == "mimetype"
|
||||
assert zf.read("mimetype") == b"application/epub+zip"
|
||||
|
||||
def test_epub_required_files(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = set(zf.namelist())
|
||||
assert "META-INF/container.xml" in names
|
||||
assert "OEBPS/content.opf" in names
|
||||
assert "OEBPS/toc.ncx" in names
|
||||
assert "OEBPS/chapter1.xhtml" in names
|
||||
|
||||
def test_epub_content_rendered(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
xhtml = zf.read("OEBPS/chapter1.xhtml").decode("utf-8")
|
||||
assert "Introduction" in xhtml
|
||||
assert "Python" in xhtml
|
||||
|
||||
def test_epub_missing_file_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_epub(vault, vault / "nope.md")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# API endpoints
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportApi:
|
||||
def test_export_html_endpoint(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "note1.md"})
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
assert resp.content.startswith(b"<!DOCTYPE html>")
|
||||
|
||||
def test_export_md_bundle_endpoint(self, client):
|
||||
resp = client.get("/api/export/md-bundle", params={"vault": "TestVault", "path": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/zip"
|
||||
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||
assert "note1.md" in zf.namelist()
|
||||
|
||||
def test_export_epub_endpoint(self, client):
|
||||
resp = client.get("/api/export/epub", params={"vault": "TestVault", "path": "note1.md"})
|
||||
assert resp.status_code == 200
|
||||
assert "epub" in resp.headers["content-type"]
|
||||
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||
assert zf.namelist()[0] == "mimetype"
|
||||
|
||||
def test_export_missing_file_returns_400(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "nope.md"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_export_unknown_vault_returns_404(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "NoVault", "path": "x.md"})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user