diff --git a/backend/export.py b/backend/export.py new file mode 100644 index 0000000..c6219b4 --- /dev/null +++ b/backend/export.py @@ -0,0 +1,622 @@ +""" +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