""" 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_html(base64_url: str, media_type: str, alt_text: str) -> str: """Generate appropriate HTML for embedded media based on type.""" safe_alt = alt_text.replace('"', '"') if media_type == 'audio': return f'''
{safe_alt}
''' elif media_type == 'video': return f'''
''' elif media_type == 'document': return f'''
''' else: # image return f'![{alt_text}]({base64_url})' 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: ![alt](path) - Wikilink media: ![[file.png]] or ![[file.mp3|alt text]] For images: returns markdown ![alt](base64) 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] # Find the media file resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir) if resolved_path: result = get_media_as_base64(resolved_path) if result: base64_url, media_type = result return generate_media_html(base64_url, media_type, alt_text) # Media not found, convert to placeholder media_type = get_media_type(media_name) icon = '🎵' if media_type == 'audio' else '🎬' if media_type == 'video' else '📄' if media_type == 'document' else '🖼️' return f'{icon} {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) # 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 result = get_media_as_base64(resolved_path) if result: base64_url, media_type = result display_alt = alt_text or resolved_path.stem return generate_media_html(base64_url, media_type, display_alt) # Media 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 </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