diff --git a/Dockerfile b/Dockerfile index 0ae4ac4..12b388c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,5 +45,5 @@ HEALTHCHECK --interval=60s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.getenv(\"PORT\", \"8000\")}/health')" # Run the application (shell form to allow environment variable expansion) -CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT +CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT --timeout-graceful-shutdown 2 diff --git a/backend/export.py b/backend/export.py index c6219b4..34cfd4a 100644 --- a/backend/export.py +++ b/backend/export.py @@ -1,36 +1,71 @@ """ HTML Export Module for NoteDiscovery -Generates standalone HTML files for notes with embedded images and styling. +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 +from typing import Optional, Tuple 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(): +# 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(image_path)) - if not mime_type or not mime_type.startswith('image/'): + 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(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}" + 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 image {image_path}: {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. @@ -57,22 +92,22 @@ def strip_frontmatter(content: str) -> str: return '\n'.join(lines[end_idx + 1:]).strip() -def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]: +def find_media_in_attachments(media_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]: """ - Search for an image file in common attachment locations. + Search for a media file in common attachment locations. Returns the resolved path if found, None otherwise. """ - # Common locations to search for images (fast path) + # Common locations to search for media (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 + 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' / image_name) + search_paths.append(current / '_attachments' / media_name) current = current.parent for path in search_paths: @@ -86,11 +121,11 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat continue # Fallback: search all _attachments folders recursively (slower but thorough) - # This handles cross-folder image references like in Obsidian + # 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 / image_name + candidate = attachment_folder / media_name if candidate.exists() and candidate.is_file(): try: candidate.resolve().relative_to(notes_dir.resolve()) @@ -103,63 +138,98 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat return None -def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: +# 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 image references in markdown and embed them as base64. + Find all media references in markdown and embed them as base64. Handles: - Standard markdown images: ![alt](path) - - Wikilink images: ![[image.png]] or ![[image.png|alt text]] + - 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 images: ![[image.png]] or ![[image.png|alt text]] - wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]' + # First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]] + wikilink_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] + 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 image - resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) + # Find the media file + 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})' + result = get_media_as_base64(resolved_path) + if result: + base64_url, media_type = result + return generate_media_html(base64_url, media_type, alt_text) - # Image not found, convert to placeholder - return f'![{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_img_pattern, replace_wikilink_image, markdown_content) + markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content) # Then, handle standard markdown images: ![alt](path) img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)' - def replace_image(match): + def replace_media(match): alt_text = match.group(1) - img_path = match.group(2) + media_path = match.group(2) # Skip external URLs and already-embedded base64 - if img_path.startswith(('http://', 'https://', 'data:')): + if media_path.startswith(('http://', 'https://', 'data:')): return match.group(0) # Skip empty paths (from failed wikilink conversion) - if not img_path: + if not media_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/'):] + # 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 image path relative to note folder - resolved_path = (note_folder / img_path).resolve() + # 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 - image_name = Path(img_path).name - resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) + 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 @@ -171,18 +241,25 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: return match.group(0) # Get base64 data - base64_url = get_image_as_base64(resolved_path) - if base64_url: - return f'![{alt_text}]({base64_url})' + 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) - # Image not found, keep original + # Media not found, keep original return match.group(0) - markdown_content = re.sub(img_pattern, replace_image, markdown_content) + 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. diff --git a/backend/main.py b/backend/main.py index 2258440..828793e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -476,90 +476,112 @@ async def create_new_folder(request: Request, data: dict): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder")) -@api_router.get("/images/{image_path:path}", tags=["Images"]) -async def get_image(image_path: str): +@api_router.get("/media/{media_path:path}", tags=["Media"]) +async def get_media(media_path: str): """ - Serve an image file with authentication protection. + Serve a media file (image, audio, video, PDF) with authentication protection. """ try: + from backend.utils import ALL_MEDIA_EXTENSIONS + notes_dir = config['storage']['notes_dir'] - full_path = Path(notes_dir) / image_path + full_path = Path(notes_dir) / media_path # Security: Validate path is within notes directory if not validate_path_security(notes_dir, full_path): raise HTTPException(status_code=403, detail="Access denied") - # Check file exists and is an image + # Check file exists if not full_path.exists() or not full_path.is_file(): - raise HTTPException(status_code=404, detail="Image not found") + raise HTTPException(status_code=404, detail="File not found") - # Validate it's an image file - allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} - if full_path.suffix.lower() not in allowed_extensions: - raise HTTPException(status_code=400, detail="Not an image file") + # Validate it's an allowed media file + if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not an allowed media file") # Return the file return FileResponse(full_path) except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image")) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file")) -@api_router.post("/upload-image", tags=["Images"]) +@api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): +async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): """ - Upload an image file and save it to the attachments directory. - Returns the relative path to the image for markdown linking. + Upload a media file (image, audio, video, PDF) and save it to the attachments directory. + Returns the relative path for markdown linking. """ try: - # Validate file type - allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'} - allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type + + # Allowed MIME types for each category + allowed_types = { + # Images + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', + # Audio + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', + # Video + 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', + # Documents + 'application/pdf', + } # Get file extension file_ext = Path(file.filename).suffix.lower() if file.filename else '' - if file.content_type not in allowed_types and file_ext not in allowed_extensions: + if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS: raise HTTPException( status_code=400, - detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}" + detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}" ) # Read file data file_data = await file.read() - # Validate file size (10MB max) - max_size = 10 * 1024 * 1024 # 10MB in bytes + # Validate file size - different limits for different types + media_type = get_media_type(file.filename) if file.filename else None + + # Size limits: images 10MB, audio 50MB, video 100MB, PDF 20MB + size_limits = { + 'image': 10 * 1024 * 1024, + 'audio': 50 * 1024 * 1024, + 'video': 100 * 1024 * 1024, + 'document': 20 * 1024 * 1024, + } + max_size = size_limits.get(media_type, 10 * 1024 * 1024) + if len(file_data) > max_size: raise HTTPException( status_code=400, - detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" + detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" ) - # Save the image - image_path = save_uploaded_image( + # Save the file (reusing image save function - it works for any file) + file_path = save_uploaded_image( config['storage']['notes_dir'], note_path, file.filename, file_data ) - if not image_path: - raise HTTPException(status_code=500, detail="Failed to save image") + if not file_path: + raise HTTPException(status_code=500, detail="Failed to save file") return { "success": True, - "path": image_path, - "filename": Path(image_path).name, - "message": "Image uploaded successfully" + "path": file_path, + "filename": Path(file_path).name, + "type": media_type, + "message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully" } except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image")) + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file")) @api_router.post("/notes/move", tags=["Notes"]) diff --git a/backend/utils.py b/backend/utils.py index 7f093ac..ae4af02 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -486,38 +486,62 @@ def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data return None +# Media file type definitions +MEDIA_EXTENSIONS = { + 'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'}, + 'audio': {'.mp3', '.wav', '.ogg', '.m4a'}, + 'video': {'.mp4', '.webm', '.mov', '.avi'}, + 'document': {'.pdf'}, +} + +# All supported media extensions (flat set for quick lookup) +ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values()) + + +def get_media_type(filename: str) -> Optional[str]: + """ + Determine the media type based on file extension. + Returns: 'image', 'audio', 'video', 'document', or None if not a media file. + """ + ext = Path(filename).suffix.lower() + for media_type, extensions in MEDIA_EXTENSIONS.items(): + if ext in extensions: + return media_type + return None + + def get_all_images(notes_dir: str) -> List[Dict]: """ - Get all images from attachments directories. - Returns list of image dictionaries with metadata. + Get all media files (images, audio, video, documents) from attachments directories. + Returns list of media dictionaries with metadata. + Note: Function name kept as 'get_all_images' for backward compatibility. """ - images = [] + media_files = [] notes_path = Path(notes_dir) - # Common image extensions - image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} - # Find all attachments directories for attachments_dir in notes_path.rglob("_attachments"): if not attachments_dir.is_dir(): continue - # Find all images in this attachments directory - for image_file in attachments_dir.iterdir(): - if image_file.is_file() and image_file.suffix.lower() in image_extensions: - relative_path = image_file.relative_to(notes_path) - stat = image_file.stat() - - images.append({ - "name": image_file.name, - "path": str(relative_path.as_posix()), - "folder": str(relative_path.parent.as_posix()), - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), - "size": stat.st_size, - "type": "image" - }) + # Find all media files in this attachments directory + for media_file in attachments_dir.iterdir(): + if media_file.is_file(): + media_type = get_media_type(media_file.name) + if media_type: + relative_path = media_file.relative_to(notes_path) + stat = media_file.stat() + + media_files.append({ + "name": media_file.name, + "path": str(relative_path.as_posix()), + "folder": str(relative_path.parent.as_posix()), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "size": stat.st_size, + "type": media_type # 'image', 'audio', 'video', or 'document' + }) - return images + return media_files def parse_tags(content: str) -> List[str]: diff --git a/documentation/API.md b/documentation/API.md index 7705d26..2c08106 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -81,66 +81,71 @@ Content-Type: application/json } ``` -## 🖼️ Images +## 🎬 Media -### Get Image +### Get Media ```http -GET /api/images/{image_path} +GET /api/media/{media_path} ``` -Retrieve an image file with authentication protection. +Retrieve a media file (image, audio, video, PDF) with authentication protection. **Example:** ```bash -curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png +curl http://localhost:8000/api/media/folder/_attachments/image-20240417093343.png ``` **Security Note:** This endpoint requires authentication and validates that: -- The image path is within the notes directory (prevents directory traversal) -- The file exists and is a valid image format +- The media path is within the notes directory (prevents directory traversal) +- The file exists and is a valid media format - The requesting user is authenticated (if auth is enabled) -### Upload Image +### Upload Media ```http -POST /api/upload-image +POST /api/upload-media Content-Type: multipart/form-data -file: +file: note_path: ``` -Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts. +Upload a media file to the `_attachments` directory. Files are automatically organized per-folder and named with timestamps to prevent conflicts. -**Supported formats:** JPG, JPEG, PNG, GIF, WEBP -**Maximum size:** 10MB +**Supported formats & size limits:** +| Type | Formats | Max Size | +|------|---------|----------| +| Images | JPG, PNG, GIF, WebP | 10 MB | +| Audio | MP3, WAV, OGG | 50 MB | +| Video | MP4, WebM | 100 MB | +| Documents | PDF | 20 MB | **Response:** ```json { "success": true, - "path": "folder/_attachments/image-20240417093343.png", - "filename": "image-20240417093343.png", - "message": "Image uploaded successfully" + "path": "folder/_attachments/media-20240417093343.png", + "filename": "media-20240417093343.png", + "message": "Media uploaded successfully" } ``` **Example (using curl):** ```bash -curl -X POST http://localhost:8000/api/upload-image \ - -F "file=@/path/to/image.png" \ +curl -X POST http://localhost:8000/api/upload-media \ + -F "file=@/path/to/file.mp3" \ -F "note_path=folder/mynote.md" ``` **Windows PowerShell:** ```powershell -curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md" +curl.exe -X POST http://localhost:8000/api/upload-media -F "file=@C:\path\to\video.mp4" -F "note_path=folder/mynote.md" ``` **Notes:** -- Images are stored in `_attachments` folders relative to the note's location -- Filenames are automatically timestamped (e.g., `image-20240417093343.png`) -- Images appear in the sidebar navigation and can be viewed/deleted directly -- Drag & drop images into the editor automatically uploads and inserts markdown -- All image access requires authentication when security is enabled +- Media is stored in `_attachments` folders relative to the note's location +- Filenames are automatically timestamped (e.g., `media-20240417093343.mp3`) +- Media appears in the sidebar navigation and can be viewed/deleted directly +- Drag & drop files into the editor automatically uploads and inserts markdown +- All media access requires authentication when security is enabled ## 📁 Folders diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 2a3e674..074fa1b 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -15,10 +15,14 @@ - **HTML Export** - Export notes as standalone HTML files with embedded images - **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md)) -### Image Support -- **Drag & drop upload** - Drop images from your file system directly into the editor +### Media Support +- **Drag & drop upload** - Drop files from your file system directly into the editor - **Clipboard paste** - Paste images from clipboard with Ctrl+V -- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB) +- **Images** - JPG, PNG, GIF, WebP (max 10MB) +- **Audio** - MP3, WAV, OGG (max 50MB) +- **Video** - MP4, WebM (max 100MB) +- **Documents** - PDF (max 20MB) +- **In-app viewing** - View all media types directly in the sidebar ### Organization - **Folder hierarchy** - Organize notes in nested folders diff --git a/frontend/app.js b/frontend/app.js index 8593e1e..ec63ceb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -164,9 +164,9 @@ function noteApp() { byEndPath: new Map(), // '/filename' and '/filename.md' -> true }, - // Image lookup map for O(1) image wikilink resolution (built on loadNotes) - // Maps image filename (case-insensitive) -> full path - _imageLookup: new Map(), + // Media lookup map for O(1) media wikilink resolution (built on loadNotes) + // Maps media filename (case-insensitive) -> full path + _mediaLookup: new Map(), // Preview rendering debounce _previewDebounceTimeout: null, @@ -422,8 +422,9 @@ function noteApp() { // Mermaid state cache lastMermaidTheme: null, - // Image viewer state - currentImage: '', + // Media viewer state + currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility) + currentMediaType: 'image', // 'image', 'audio', 'video', 'document' // DOM element cache (to avoid repeated querySelector calls) _domCache: { @@ -1038,7 +1039,7 @@ function noteApp() { this._noteLookup.byName.clear(); this._noteLookup.byNameLower.clear(); this._noteLookup.byEndPath.clear(); - this._imageLookup.clear(); + this._mediaLookup.clear(); for (const note of this.notes) { const path = note.path; @@ -1046,12 +1047,12 @@ function noteApp() { const name = note.name; const nameLower = name.toLowerCase(); - // Handle images separately - build image lookup map - if (note.type === 'image') { + // Handle media files separately - build media lookup map + if (note.type !== 'note') { // Map filename (case-insensitive) to full path // First match wins if there are duplicates - if (!this._imageLookup.has(nameLower)) { - this._imageLookup.set(nameLower, path); + if (!this._mediaLookup.has(nameLower)) { + this._mediaLookup.set(nameLower, path); } continue; } @@ -1093,11 +1094,11 @@ function noteApp() { ); }, - // Resolve image wikilink to full path (O(1) lookup) + // Resolve media wikilink to full path (O(1) lookup) // Returns the full path if found, null otherwise - resolveImageWikilink(imageName) { - const nameLower = imageName.toLowerCase(); - return this._imageLookup.get(nameLower) || null; + resolveMediaWikilink(mediaName) { + const nameLower = mediaName.toLowerCase(); + return this._mediaLookup.get(nameLower) || null; }, // Load all tags @@ -1782,14 +1783,14 @@ function noteApp() { }, handleItemHover(el, isEnter) { const path = el.dataset.path; - if (path !== this.currentNote && path !== this.currentImage) { + if (path !== this.currentNote && path !== this.currentMedia) { el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent'; } }, handleDeleteItemClick(el, event) { event.stopPropagation(); if (el.dataset.type === 'image') { - this.deleteImage(el.dataset.path); + this.deleteMedia(el.dataset.path); } else { this.deleteNote(el.dataset.path, el.dataset.name); } @@ -1893,16 +1894,16 @@ function noteApp() { // Then, render notes and images in this folder (after subfolders) if (folder.notes && folder.notes.length > 0) { folder.notes.forEach(note => { - // Check if it's an image or a note - const isImage = note.type === 'image'; + // Check if it's a media file or a note + const isMediaFile = note.type !== 'note'; const isCurrentNote = this.currentNote === note.path; - const isCurrentImage = this.currentImage === note.path; - const isCurrent = isImage ? isCurrentImage : isCurrentNote; + const isCurrentMedia = this.currentMedia === note.path; + const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote; - // Different icon for images, share icon for shared notes - const isShared = !isImage && this.isNoteShared(note.path); + // Icon based on media type, share icon for shared notes + const isShared = !isMediaFile && this.isNoteShared(note.path); const shareIcon = isShared ? '' : ''; - const icon = isImage ? '🖼️' : ''; + const icon = this.getMediaIcon(note.type); html += `
@@ -1926,7 +1927,7 @@ function noteApp() { onclick="window.$root.handleDeleteItemClick(this, event)" class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity" style="opacity: 0; color: var(--error);" - title="${isImage ? 'Delete image' : 'Delete note'}" + title="${isMediaFile ? 'Delete file' : 'Delete note'}" > @@ -2037,18 +2038,18 @@ function noteApp() { // Drag and drop handlers onNoteDragStart(notePath, event) { - // Check if this is an image + // Check if this is a media file const item = this.notes.find(n => n.path === notePath); - const isImage = item && item.type === 'image'; + const isMediaFile = item && item.type !== 'note'; // Set unified drag state this.draggedItem = { path: notePath, - type: isImage ? 'image' : 'note' + type: item ? item.type : 'note' }; // For notes, also set legacy draggedNote for folder move logic - if (!isImage) { + if (!isMediaFile) { this.draggedNote = notePath; // Make drag image semi-transparent if (event.target) { @@ -2141,26 +2142,26 @@ function noteApp() { } }, - // Handle drop into editor to create internal link or upload image + // Handle drop into editor to create internal link or upload media async onEditorDrop(event) { event.preventDefault(); this.dropTarget = null; - // Check if files are being dropped (images from file system) + // Check if files are being dropped (media from file system) if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { - await this.handleImageDrop(event); + await this.handleMediaDrop(event); return; } - // Otherwise, handle note/image link drop from sidebar + // Otherwise, handle note/media link drop from sidebar if (!this.draggedItem) return; const notePath = this.draggedItem.path; - const isImage = this.draggedItem.type === 'image'; + const isMediaFile = this.draggedItem.type !== 'note'; let link; - if (isImage) { - // For images, use wiki-style link (resolves by filename, never breaks) + if (isMediaFile) { + // For media files (images, audio, video, PDF), use wiki-style embed link const filename = notePath.split('/').pop(); link = `![[${filename}]]`; } else { @@ -2192,22 +2193,30 @@ function noteApp() { this.draggedItem = null; }, - // Handle image files dropped into editor - async handleImageDrop(event) { + // Handle media files dropped into editor + async handleMediaDrop(event) { if (!this.currentNote) { alert(this.t('notes.open_first')); return; } const files = Array.from(event.dataTransfer.files); - const imageFiles = files.filter(file => { - const type = file.type.toLowerCase(); - return type.startsWith('image/') && - ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'].includes(type); - }); - if (imageFiles.length === 0) { - alert(this.t('images.no_valid_files')); + // Filter for allowed media types + const allowedTypes = [ + // Images + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', + // Audio + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', + // Video + 'video/mp4', 'video/webm', 'video/quicktime', + // Documents + 'application/pdf' + ]; + const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase())); + + if (mediaFiles.length === 0) { + alert(this.t('media.no_valid_files')); return; } @@ -2216,27 +2225,27 @@ function noteApp() { let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; - // Upload each image - for (const file of imageFiles) { + // Upload each media file + for (const file of mediaFiles) { try { - const imagePath = await this.uploadImage(file, this.currentNote); - if (imagePath) { - await this.insertImageMarkdown(imagePath, file.name, cursorPos); + const mediaPath = await this.uploadMedia(file, this.currentNote); + if (mediaPath) { + await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); } } catch (error) { - ErrorHandler.handle(`upload image ${file.name}`, error); + ErrorHandler.handle(`upload file ${file.name}`, error); } } }, - // Upload an image file - async uploadImage(file, notePath) { + // Upload a media file (image, audio, video, PDF) + async uploadMedia(file, notePath) { const formData = new FormData(); formData.append('file', file); formData.append('note_path', notePath); try { - const response = await fetch('/api/upload-image', { + const response = await fetch('/api/upload-media', { method: 'POST', body: formData }); @@ -2253,13 +2262,13 @@ function noteApp() { } }, - // Insert image markdown at cursor position using wiki-style syntax - // This ensures image links don't break when notes are moved - async insertImageMarkdown(imagePath, altText, cursorPos) { + // Insert media markdown at cursor position using wiki-style syntax + // This ensures media links don't break when notes are moved + async insertMediaMarkdown(mediaPath, altText, cursorPos) { // Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png") - const filename = imagePath.split('/').pop(); + const filename = mediaPath.split('/').pop(); - // Use wiki-style image link: ![[filename.png]] or ![[filename.png|alt text]] + // Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]] // The alt text is optional - only add if different from filename const filenameWithoutExt = filename.replace(/\.[^/.]+$/, ''); const altWithoutExt = altText.replace(/\.[^/.]+$/, ''); @@ -2281,7 +2290,7 @@ function noteApp() { this.autoSave(); }, - // Handle paste event for clipboard images + // Handle paste event for clipboard media (images) async handlePaste(event) { if (!this.currentNote) return; @@ -2305,77 +2314,112 @@ function noteApp() { // Create a File from the blob const file = new File([blob], filename, { type: item.type }); - const imagePath = await this.uploadImage(file, this.currentNote); - if (imagePath) { - await this.insertImageMarkdown(imagePath, filename, cursorPos); + const mediaPath = await this.uploadMedia(file, this.currentNote); + if (mediaPath) { + await this.insertMediaMarkdown(mediaPath, filename, cursorPos); } } catch (error) { - ErrorHandler.handle('paste image', error); + ErrorHandler.handle('paste media', error); } } - break; // Only handle first image + break; // Only handle first media item } } }, - // Open a note or image (unified handler for sidebar/homepage clicks) + // Media type detection based on file extension + getMediaType(filename) { + if (!filename) return null; + const ext = filename.split('.').pop().toLowerCase(); + const mediaTypes = { + image: ['jpg', 'jpeg', 'png', 'gif', 'webp'], + audio: ['mp3', 'wav', 'ogg', 'm4a'], + video: ['mp4', 'webm', 'mov', 'avi'], + document: ['pdf'], + }; + for (const [type, extensions] of Object.entries(mediaTypes)) { + if (extensions.includes(ext)) return type; + } + return null; + }, + + // Get icon for media type + getMediaIcon(type) { + const icons = { + image: '🖼️', + audio: '🎵', + video: '🎬', + document: '📄', + }; + return icons[type] || ''; + }, + + // Open a note or media file (unified handler for sidebar/homepage clicks) openItem(path, type = 'note', searchHighlight = '') { this.showGraph = false; - if (type === 'image' || path.match(/\.(png|jpg|jpeg|gif|webp)$/i)) { - this.viewImage(path); + // Check if it's a media file by type or extension + const mediaType = type !== 'note' ? type : this.getMediaType(path); + if (mediaType && mediaType !== 'note') { + this.viewMedia(path, mediaType); } else { this.loadNote(path, true, searchHighlight); } }, - // View an image in the main pane - viewImage(imagePath, updateHistory = true) { + // View a media file (image, audio, video, PDF) in the main pane + viewMedia(mediaPath, mediaType = null, updateHistory = true) { this.showGraph = false; // Ensure graph is closed this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; - this.currentImage = imagePath; + this.currentMedia = mediaPath; // Reuse currentMedia for all media + this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image'; this.shareInfo = null; // Reset share info - this.viewMode = 'preview'; // Use preview mode to show image + this.viewMode = 'preview'; // Use preview mode to show media - // Update browser tab title for image - const imageName = imagePath.split('/').pop(); - document.title = `${imageName} - ${this.appName}`; + // Update browser tab title + const fileName = mediaPath.split('/').pop(); + document.title = `${fileName} - ${this.appName}`; // Update browser URL if (updateHistory) { // Encode each path segment to handle special characters - const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); + const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/'); window.history.pushState( - { imagePath: imagePath }, + { mediaPath: mediaPath }, '', `/${encodedPath}` ); } }, - // Delete an image - async deleteImage(imagePath) { - const filename = imagePath.split('/').pop(); - if (!confirm(this.t('images.confirm_delete', { name: filename }))) return; + // Backward compatibility alias + viewImage(mediaPath, updateHistory = true) { + this.viewMedia(mediaPath, 'image', updateHistory); + }, + + // Delete a media file (image, audio, video, PDF) + async deleteMedia(mediaPath) { + const filename = mediaPath.split('/').pop(); + if (!confirm(this.t('media.confirm_delete', { name: filename }))) return; try { - const response = await fetch(`/api/notes/${encodeURIComponent(imagePath)}`, { + const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, { method: 'DELETE' }); if (response.ok) { await this.loadNotes(); // Refresh tree - // Clear viewer if deleting currently viewed image - if (this.currentImage === imagePath) { - this.currentImage = ''; + // Clear viewer if deleting currently viewed media + if (this.currentMedia === mediaPath) { + this.currentMedia = ''; } } else { - throw new Error('Failed to delete image'); + throw new Error('Failed to delete media file'); } } catch (error) { - ErrorHandler.handle('delete image', error); + ErrorHandler.handle('delete media', error); } }, @@ -2655,7 +2699,7 @@ function noteApp() { window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); this.currentNote = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; document.title = this.appName; return; } @@ -2669,7 +2713,7 @@ function noteApp() { this._cachedRenderedHTML = ''; this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); - this.currentImage = ''; // Clear image viewer when loading a note + this.currentMedia = ''; // Clear image viewer when loading a note this.shareInfo = null; // Reset share info for new note // Update browser tab title @@ -3944,20 +3988,20 @@ function noteApp() { return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`; }); - // Step 2: Convert image wikilinks FIRST: ![[image.png]] or ![[image.png|alt text]] - // Must be before note wikilinks to prevent [[image.png]] from being matched first + // Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]] + // Must be before note wikilinks to prevent [[file.png]] from being matched first contentToRender = contentToRender.replace( /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, - (match, imageName, altText) => { - const filename = imageName.trim(); + (match, mediaName, altText) => { + const filename = mediaName.trim(); const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, ''); - // Resolve image path using O(1) lookup - const imagePath = self.resolveImageWikilink(filename); + // Resolve media path using O(1) lookup + const mediaPath = self.resolveMediaWikilink(filename); - if (imagePath) { + if (mediaPath) { // URL-encode path segments for the API - const encodedPath = imagePath.split('/').map(segment => { + const encodedPath = mediaPath.split('/').map(segment => { try { return encodeURIComponent(decodeURIComponent(segment)); } catch (e) { @@ -3966,12 +4010,27 @@ function noteApp() { }).join('/'); const safeAlt = alt.replace(/"/g, '"'); - return `${safeAlt}`; + const mediaSrc = `/api/media/${encodedPath}`; + const mediaType = self.getMediaType(filename); + + // Return appropriate HTML based on media type + switch (mediaType) { + case 'audio': + return `
${safeAlt}
`; + case 'video': + return `
`; + case 'document': + return `
`; + default: // image + return `${safeAlt}`; + } } - // Image not found - return broken image indicator + // Media not found - return broken indicator const safeFilename = filename.replace(/&/g, '&').replace(//g, '>'); - return `🖼️ ${safeFilename}`; + const mediaType = self.getMediaType(filename); + const icon = mediaType === 'audio' ? '🎵' : mediaType === 'video' ? '🎬' : mediaType === 'document' ? '📄' : '🖼️'; + return `${icon} ${safeFilename}`; } ); @@ -4045,17 +4104,17 @@ function noteApp() { }); // Find all images and transform paths for display + // Also convert non-image media (audio, video, PDF) to appropriate elements const images = tempDiv.querySelectorAll('img'); images.forEach(img => { - const src = img.getAttribute('src'); + let src = img.getAttribute('src'); if (src) { - // Transform relative paths to /api/images/ for serving + // Transform relative paths to /api/media/ for serving // Skip external URLs and already-transformed paths if (!src.startsWith('http://') && !src.startsWith('https://') && - !src.startsWith('//') && !src.startsWith('/api/images/') && + !src.startsWith('//') && !src.startsWith('/api/media/') && !src.startsWith('data:')) { // URL-encode path segments to handle spaces and special characters - // Decode first to avoid double-encoding if path already contains %XX sequences const encodedPath = src.split('/').map(segment => { try { return encodeURIComponent(decodeURIComponent(segment)); @@ -4063,13 +4122,39 @@ function noteApp() { return encodeURIComponent(segment); } }).join('/'); - img.setAttribute('src', `/api/images/${encodedPath}`); + src = `/api/media/${encodedPath}`; + img.setAttribute('src', src); + } + + // Check if this is non-image media and convert to appropriate element + const mediaType = self.getMediaType(src); + const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, ''); + const safeAlt = altText.replace(/"/g, '"'); + + if (mediaType === 'audio') { + const wrapper = document.createElement('div'); + wrapper.className = 'media-embed media-audio'; + wrapper.innerHTML = `${safeAlt}`; + img.replaceWith(wrapper); + return; + } else if (mediaType === 'video') { + const wrapper = document.createElement('div'); + wrapper.className = 'media-embed media-video'; + wrapper.innerHTML = ``; + img.replaceWith(wrapper); + return; + } else if (mediaType === 'document') { + const wrapper = document.createElement('div'); + wrapper.className = 'media-embed media-pdf'; + wrapper.innerHTML = ``; + img.replaceWith(wrapper); + return; } } + // For regular images, set title attribute const altText = img.getAttribute('alt'); if (altText) { - // Set title attribute to show alt text on hover img.setAttribute('title', altText); } }); @@ -4892,7 +4977,7 @@ function noteApp() { const encodedPath = match[1]; try { // Fetch the image - const imgResponse = await fetch(`/api/images/${encodedPath}`); + const imgResponse = await fetch(`/api/media/${encodedPath}`); if (imgResponse.ok) { const blob = await imgResponse.blob(); // Convert to base64 data URL @@ -5417,7 +5502,7 @@ function noteApp() { this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; this.outline = []; document.title = this.appName; @@ -5439,7 +5524,7 @@ function noteApp() { this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; this.outline = []; this.mobileSidebarOpen = false; document.title = this.appName; @@ -5462,7 +5547,7 @@ function noteApp() { // Mobile files/home tab - context-aware behavior mobileFilesTabClick() { - if (this.currentNote || this.currentImage || this.showGraph) { + if (this.currentNote || this.currentMedia || this.showGraph) { // Viewing content → go home this.goHome(); } else { diff --git a/frontend/index.html b/frontend/index.html index f5b7509..b03c20a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -444,6 +444,59 @@ margin: 1rem 0; } + /* Embedded Media (audio, video, PDF) */ + .markdown-preview .media-embed { + margin: 1.5rem 0; + border-radius: 0.5rem; + overflow: hidden; + } + + /* Audio embeds */ + .markdown-preview .media-audio { + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem; + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); + border: 1px solid var(--border-primary); + } + .markdown-preview .media-audio audio { + width: 100%; + max-width: 500px; + border-radius: 2rem; + } + .markdown-preview .media-audio .media-caption { + margin-top: 0.75rem; + font-size: 0.875rem; + color: var(--text-secondary); + } + + /* Video embeds */ + .markdown-preview .media-video { + background: #000; + display: flex; + justify-content: center; + } + .markdown-preview .media-video video { + width: 100%; + max-width: 800px; + max-height: 450px; + border-radius: 0; + } + + /* PDF embeds */ + .markdown-preview .media-pdf { + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + overflow: hidden; + } + .markdown-preview .media-pdf iframe { + width: 100%; + height: 600px; + border: none; + background: #525659; + } + /* Task lists */ .markdown-preview input[type="checkbox"] { margin-right: 0.5rem; @@ -1451,19 +1504,19 @@ @dragend="onNoteDragEnd()" @click="openItem(note.path, note.type)" class="note-item px-2 py-1 text-sm relative" - :style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')" - @mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'" - @mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'" + :style="((note.type !== 'note' ? currentMedia === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type !== 'note' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')" + @mouseover="if(currentNote !== note.path && currentMedia !== note.path) $el.style.backgroundColor='var(--bg-hover)'" + @mouseout="if(currentNote !== note.path && currentMedia !== note.path) $el.style.backgroundColor='transparent'" > - +
-