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