""" HTML Export Module for NoteDiscovery Generates standalone HTML files for notes with embedded images and styling. Used by both /api/export (download) and /public (sharing) endpoints. """ import base64 import re from pathlib import Path from typing import Optional import mimetypes def get_image_as_base64(image_path: Path) -> Optional[str]: """Read an image file and return it as a base64 data URL.""" if not image_path.exists() or not image_path.is_file(): return None # Get MIME type mime_type, _ = mimetypes.guess_type(str(image_path)) if not mime_type or not mime_type.startswith('image/'): return None try: with open(image_path, 'rb') as f: image_data = f.read() base64_data = base64.b64encode(image_data).decode('utf-8') return f"data:{mime_type};base64,{base64_data}" except Exception as e: print(f"Failed to read image {image_path}: {e}") 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_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]: """ Search for an image file in common attachment locations. Returns the resolved path if found, None otherwise. """ # Common locations to search for images (fast path) search_paths = [ note_folder / image_name, # Same folder as note note_folder / '_attachments' / image_name, # Note's _attachments folder notes_dir / '_attachments' / image_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' / image_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 image references like in Obsidian try: for attachment_folder in notes_dir.rglob('_attachments'): if attachment_folder.is_dir(): candidate = attachment_folder / image_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 def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: """ Find all image references in markdown and embed them as base64. Handles: - Standard markdown images:  - Wikilink images: ![[image.png]] or ![[image.png|alt text]] """ # First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]] wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]' def replace_wikilink_image(match): image_name = match.group(1).strip() alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0] # Find the image resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) if resolved_path: base64_url = get_image_as_base64(resolved_path) if base64_url: return f'' # Image not found, convert to placeholder return f'![{alt_text}]()' markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content) # Then, handle standard markdown images:  img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)' def replace_image(match): alt_text = match.group(1) img_path = match.group(2) # Skip external URLs and already-embedded base64 if img_path.startswith(('http://', 'https://', 'data:')): return match.group(0) # Skip empty paths (from failed wikilink conversion) if not img_path: return match.group(0) # Handle /api/images/ paths (convert to filesystem paths) if img_path.startswith('/api/images/'): # Strip the /api/images/ prefix to get the relative path within notes_dir relative_path = img_path[len('/api/images/'):] resolved_path = (notes_dir / relative_path).resolve() else: # Try to resolve the image path relative to note folder resolved_path = (note_folder / img_path).resolve() # If not found, try the attachment search if not resolved_path.exists(): # Extract just the filename and search image_name = Path(img_path).name resolved_path = find_image_in_attachments(image_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 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_image, markdown_content) return markdown_content 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