"""
HTML Export Module for NoteDiscovery
Generates standalone HTML files for notes with embedded media and styling.
Used by both /api/export (download) and /public (sharing) endpoints.
"""
import base64
import re
from pathlib import Path
from typing import Optional, Tuple
import mimetypes
# Media type detection
MEDIA_EXTENSIONS = {
'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'},
'audio': {'.mp3', '.wav', '.ogg', '.m4a'},
'video': {'.mp4', '.webm'},
'document': {'.pdf'}
}
def get_media_type(filename: str) -> Optional[str]:
"""Determine media type based on file extension."""
ext = Path(filename).suffix.lower()
for media_type, extensions in MEDIA_EXTENSIONS.items():
if ext in extensions:
return media_type
return None
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:
for attachment_folder in notes_dir.rglob('_attachments'):
if attachment_folder.is_dir():
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 embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""
Find all media references in markdown and embed them as base64.
Handles:
- Standard markdown images: 
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
For images: returns markdown 
For audio/video/PDF: returns inline HTML
"""
# 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)
# Skip external URLs and already-embedded base64
if media_path.startswith(('http://', 'https://', '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
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
return embed_media_as_base64(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