""" 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 # 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 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: ![alt](path) - 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'![{alt_text}]({base64_url})' # Image not found return f'🖼️ {alt_text}' markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content) # Then, handle standard markdown images: ![alt](path) 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'![{display_alt}]({base64_url})' # 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 </script> breaking ) highlight_theme = 'github-dark' if is_dark else 'github' mermaid_theme = 'dark' if is_dark else 'default' html = f'''<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{title}
''' return html