From 7b0dacabdd430a8b6d08c740aaa2f5afe2d32b4a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 6 Sep 2026 18:42:26 -0400 Subject: [PATCH] feat(export): v2.1.0 - export multi-formats HTML/MD-bundle/ePub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/export.py | 431 ++++++++++++++++++++++++++++++++++++++++++ frontend/js/viewer.js | 69 ++++++- tests/test_export.py | 172 +++++++++++++++++ 3 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 backend/export.py create mode 100644 tests/test_export.py diff --git a/backend/export.py b/backend/export.py new file mode 100644 index 0000000..39d12fa --- /dev/null +++ b/backend/export.py @@ -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 ``![alt](src)`` 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"![{target}]({_to_data_uri(target)})" + + md = re.sub(r"!\[\[([^\]]+)\]\]", _embed, md) + + # Standard markdown images + def _img(match): + alt = match.group(1) + src = match.group(2) + return f"![{alt}]({_to_data_uri(src)})" + + 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'{html_mod.escape(display)}' + + # 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'{html_mod.escape(display)}' + + return f'{html_mod.escape(display)}' + + 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'
  • {html_mod.escape(stem)}
  • ') + if not items: + return "" + return ( + '" + ) + + +# --------------------------------------------------------------------------- +# 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""" + + + + + +{html_mod.escape(title)} + + + +

    {html_mod.escape(title)}

    +{nav} +{rendered} + +""" + 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""" + + + + {html_mod.escape(title)} + + + +

    {html_mod.escape(title)}

    + {rendered} + +""" + + # 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""" + + + {html_mod.escape(uid)} + {html_mod.escape(title)} + {html_mod.escape(author)} + fr + {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')} + + + + + + + + +""" + + toc_ncx = f""" + + + + + {html_mod.escape(title)} + + + {html_mod.escape(title)} + + + +""" + + container_xml = """ + + + + +""" + + 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() diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 78d760b..b1eced0 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -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); diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..0156aef --- /dev/null +++ b/tests/test_export.py @@ -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("") + assert "" in text + assert "