"""
HTML Export Module for NoteDiscovery
Generates standalone HTML files for notes with embedded images and styling.
Used by both /api/export (download) and /share (public sharing) endpoints.
Note: Only images are embedded as base64. Audio, video, and PDF files are
replaced with placeholder HTML since they would make exports too large.
"""
import base64
import re
from pathlib import Path
from typing import Optional, Tuple
import mimetypes
# Import shared media type definitions and scanner from utils to avoid duplication
from backend.utils import MEDIA_EXTENSIONS, get_media_type, scan_notes_fast_walk
def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
"""
Read a media file and return it as a base64 data URL.
Returns tuple of (base64_url, media_type) or None if failed.
"""
if not media_path.exists() or not media_path.is_file():
return None
# Get MIME type
mime_type, _ = mimetypes.guess_type(str(media_path))
if not mime_type:
return None
# Determine media type
media_type = get_media_type(media_path.name)
if not media_type:
return None
try:
with open(media_path, 'rb') as f:
media_data = f.read()
base64_data = base64.b64encode(media_data).decode('utf-8')
return (f"data:{mime_type};base64,{base64_data}", media_type)
except Exception as e:
print(f"Failed to read media {media_path}: {e}")
return None
# Legacy alias for backward compatibility
def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL."""
result = get_media_as_base64(image_path)
if result and result[1] == 'image':
return result[0]
return None
def strip_frontmatter(content: str) -> str:
"""
Remove YAML frontmatter from markdown content.
Frontmatter is delimited by --- at the start and end.
"""
if not content.strip().startswith('---'):
return content
lines = content.split('\n')
if lines[0].strip() != '---':
return content
# Find closing ---
end_idx = -1
for i in range(1, len(lines)):
if lines[i].strip() == '---':
end_idx = i
break
if end_idx == -1:
return content
# Remove frontmatter and return the rest
return '\n'.join(lines[end_idx + 1:]).strip()
def find_media_in_attachments(media_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
"""
Search for a media file in common attachment locations.
Returns the resolved path if found, None otherwise.
"""
# Common locations to search for media (fast path)
search_paths = [
note_folder / media_name, # Same folder as note
note_folder / '_attachments' / media_name, # Note's _attachments folder
notes_dir / '_attachments' / media_name, # Root _attachments folder
]
# Also search in parent folders' _attachments (for nested notes)
current = note_folder
while current != notes_dir and current.parent != current:
search_paths.append(current / '_attachments' / media_name)
current = current.parent
for path in search_paths:
resolved = path.resolve()
if resolved.exists() and resolved.is_file():
# Security: ensure path is within notes_dir
try:
resolved.relative_to(notes_dir.resolve())
return resolved
except ValueError:
continue
# Fallback: search all _attachments folders recursively (slower but thorough)
# This handles cross-folder media references like in Obsidian
try:
_files, folders = scan_notes_fast_walk(str(notes_dir), include_media=False)
for folder in folders:
if folder == '_attachments' or folder.endswith('/_attachments'):
attachment_folder = notes_dir / folder
candidate = attachment_folder / media_name
if candidate.exists() and candidate.is_file():
try:
candidate.resolve().relative_to(notes_dir.resolve())
return candidate.resolve()
except ValueError:
continue
except Exception:
pass # Ignore errors in recursive search
return None
# Legacy alias
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
return find_media_in_attachments(image_name, note_folder, notes_dir)
def generate_media_placeholder(media_type: str, alt_text: str) -> str:
"""Generate a placeholder for non-embeddable media (audio, video, PDF)."""
safe_alt = alt_text.replace('"', '"').replace('<', '<').replace('>', '>')
icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'}
labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'}
icon = icons.get(media_type, '📎')
label = labels.get(media_type, 'Media file')
return f'''
{icon}
{safe_alt}
{label} — not available in exported view
'''
def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""
Process all media references in markdown for standalone HTML export.
Handles:
- Standard markdown images: 
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
Behavior by media type:
- Images (jpg, png, gif, webp): Embedded as base64 data URLs
- Audio/Video/PDF: Replaced with styled placeholder HTML (not embedded - too large)
"""
# First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink_media(match):
media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
# Check media type first
media_type = get_media_type(media_name)
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, alt_text)
# For images, embed as base64
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if resolved_path:
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f''
# Image not found
return f'🖼️ {alt_text}'
markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
# Then, handle standard markdown images: 
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
def replace_media(match):
alt_text = match.group(1)
media_path = match.group(2)
# Handle external URLs
if media_path.startswith(('http://', 'https://')):
# Check if it's a PDF - generate styled external link
media_type = get_media_type(media_path)
if media_type == 'document':
display_name = alt_text or Path(media_path).stem
safe_name = display_name.replace('"', '"').replace('<', '<').replace('>', '>')
safe_url = media_path.replace('"', '"')
return f'''📄 {safe_name}Opens in new tab'''
# Other external media: keep as-is (will show as broken image)
return match.group(0)
# Skip already-embedded base64
if media_path.startswith('data:'):
return match.group(0)
# Skip empty paths (from failed wikilink conversion)
if not media_path:
return match.group(0)
# Check media type first
media_type = get_media_type(media_path)
display_alt = alt_text or Path(media_path).stem
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, display_alt)
# For images, proceed with base64 embedding
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if media_path.startswith('/api/media/'):
relative_path = media_path[len('/api/media/'):]
resolved_path = (notes_dir / relative_path).resolve()
elif media_path.startswith('/api/images/'):
# Legacy path support for backward compatibility
relative_path = media_path[len('/api/images/'):]
resolved_path = (notes_dir / relative_path).resolve()
else:
# Try to resolve the media path relative to note folder
resolved_path = (note_folder / media_path).resolve()
# If not found, try the attachment search
if not resolved_path.exists():
# Extract just the filename and search
media_name = Path(media_path).name
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if not resolved_path:
return match.group(0) # Keep original if not found
# Security: ensure path is within notes_dir
try:
resolved_path.relative_to(notes_dir.resolve())
except ValueError:
# Path is outside notes_dir, skip
return match.group(0)
# Get base64 data for image
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f''
# Image not found, keep original
return match.group(0)
markdown_content = re.sub(img_pattern, replace_media, markdown_content)
return markdown_content
# Legacy alias for backward compatibility
# Legacy alias for backward compatibility
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""Alias for process_media_for_export (legacy name kept for compatibility)."""
return process_media_for_export(markdown_content, note_folder, notes_dir)
def convert_wikilinks_to_html(markdown_content: str) -> str:
"""
Convert wikilinks [[note]] or [[note|display text]] to HTML links.
In standalone export mode, these are non-functional decorative links.
"""
# Pattern for wikilinks: [[target]] or [[target|display text]]
# But NOT image wikilinks (those start with !)
wikilink_pattern = r'(?{display}'
return re.sub(wikilink_pattern, replace_wikilink, markdown_content)
def generate_export_html(
title: str,
content: str,
theme_css: str,
is_dark: bool = False
) -> str:
"""
Generate a standalone HTML document for a note.
Uses marked.js for client-side markdown rendering.
Args:
title: The note title (for and display)
content: Raw markdown content (images should already be base64 embedded)
theme_css: CSS content for theming
is_dark: Whether using a dark theme (for Mermaid/Highlight.js)
Returns:
Complete HTML document as string
"""
# Escape content for JavaScript string
escaped_content = (
content
.replace('\\', '\\\\')
.replace('`', '\\`')
.replace('$', '\\$')
.replace('', '<\\/') # Prevent breaking
)
highlight_theme = 'github-dark' if is_dark else 'github'
mermaid_theme = 'dark' if is_dark else 'default'
html = f'''
{title}
'''
return html