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..1ef7095 100644 --- a/backend/export.py +++ b/backend/export.py @@ -1,36 +1,59 @@ """ 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. +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 +from typing import Optional, Tuple import mimetypes +# Import shared media type definitions from utils to avoid duplication +from backend.utils import MEDIA_EXTENSIONS, get_media_type -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(): + +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 +80,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 +109,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 +126,125 @@ 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_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: """ - Find all image references in markdown and embed them as base64. + Process all media references in markdown for standalone HTML export. + Handles: - Standard markdown images: ![alt](path) - - Wikilink images: ![[image.png]] or ![[image.png|alt text]] + - 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 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) + # 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, convert to placeholder - return f'![{alt_text}]()' + # Image not found + return f'🖼️ {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:')): + # Handle external URLs + if media_path.startswith(('http://', 'https://')): + # Check if it's a PDF - generate styled external link + media_type = get_media_type(media_path) + if media_type == 'document': + display_name = alt_text or Path(media_path).stem + safe_name = display_name.replace('"', '"').replace('<', '<').replace('>', '>') + safe_url = media_path.replace('"', '"') + return f''' +📄 {safe_name} +Opens in new tab +''' + # Other external media: keep as-is (will show as broken image) + return match.group(0) + + # Skip already-embedded base64 + if media_path.startswith('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/'):] + # 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 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 @@ -170,19 +255,26 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: # Path is outside notes_dir, skip return match.group(0) - # Get base64 data + # Get base64 data for image base64_url = get_image_as_base64(resolved_path) if base64_url: - return f'![{alt_text}]({base64_url})' + return f'![{display_alt}]({base64_url})' # Image 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 +# 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. diff --git a/backend/main.py b/backend/main.py index 2258440..b7e3c18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -476,90 +476,162 @@ 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("/media/move", tags=["Media"]) +@limiter.limit("30/minute") +async def move_media_endpoint(request: Request, data: dict): + """Move a media file to a different folder""" + try: + from backend.utils import ALL_MEDIA_EXTENSIONS + + old_path = data.get('oldPath', '') + new_path = data.get('newPath', '') + + if not old_path or not new_path: + raise HTTPException(status_code=400, detail="Both oldPath and newPath required") + + notes_dir = config['storage']['notes_dir'] + old_full_path = Path(notes_dir) / old_path + new_full_path = Path(notes_dir) / new_path + + # Security: Validate paths are within notes directory + if not validate_path_security(notes_dir, old_full_path): + raise HTTPException(status_code=403, detail="Invalid source path") + if not validate_path_security(notes_dir, new_full_path): + raise HTTPException(status_code=403, detail="Invalid destination path") + + # Validate it's a media file + if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not a valid media file") + + # Check source exists + if not old_full_path.exists(): + raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}") + + # Check target doesn't exist + if new_full_path.exists(): + raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}") + + # Create parent directory if needed + new_full_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the file + import shutil + shutil.move(str(old_full_path), str(new_full_path)) + + return {"success": True, "message": "Media moved successfully", "newPath": new_path} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file")) @api_router.post("/notes/move", tags=["Notes"]) diff --git a/backend/utils.py b/backend/utils.py index 7f093ac..5703607 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -486,38 +486,63 @@ 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 anywhere in the notes directory. + 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(): + # Find all media files recursively in the entire notes directory + for media_file in notes_path.rglob("*"): + # Skip directories and hidden files/folders + if media_file.is_dir(): + continue + if any(part.startswith('.') for part in media_file.parts): 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" - }) + # Check if it's a media 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()) if relative_path.parent != Path('.') else "", + "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..27c32dd 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -81,66 +81,93 @@ 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, M4A | 50 MB | +| Video | MP4, WebM, MOV, AVI | 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" +``` + +### Move Media +```http +POST /api/media/move +Content-Type: application/json + +{ + "oldPath": "_attachments/image.png", + "newPath": "folder/_attachments/image.png" +} +``` + +Move a media file to a different location. Supports drag & drop in the UI. + +**Response:** +```json +{ + "success": true, + "message": "Media moved successfully", + "newPath": "folder/_attachments/image.png" +} ``` **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..1209d35 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -15,10 +15,15 @@ - **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, M4A (max 50MB) +- **Video** - MP4, WebM, MOV, AVI (max 100MB) +- **Documents** - PDF (max 20MB) +- **In-app viewing** - View all media types directly in the sidebar +- **Inline preview** - Audio/video players and PDF viewer embedded in notes ### Organization - **Folder hierarchy** - Organize notes in nested folders diff --git a/frontend/app.js b/frontend/app.js index 8593e1e..52ce08b 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, @@ -196,8 +196,6 @@ function noteApp() { folderTree: [], allFolders: [], expandedFolders: new Set(), - draggedNote: null, - draggedFolder: null, dragOverFolder: null, // Track which folder is being hovered during drag // Tags state @@ -212,8 +210,8 @@ function noteApp() { // Scroll sync state isScrolling: false, - // Unified drag state - draggedItem: null, // { path: string, type: 'note' | 'image' } + // Unified drag state for notes, folders, and media + draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' } dropTarget: null, // 'editor' | 'folder' | null // Undo/Redo history @@ -422,8 +420,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: { @@ -443,7 +442,7 @@ function noteApp() { // ESC key to cancel drag operations document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && (this.draggedNote || this.draggedFolder || this.draggedItem)) { + if (e.key === 'Escape' && this.draggedItem) { this.cancelDrag(); } }); @@ -467,7 +466,7 @@ function noteApp() { this.loadSyntaxHighlightSetting(); // Parse URL and load specific note if provided - this.loadNoteFromURL(); + this.loadItemFromURL(); // Set initial homepage state ONLY if we're actually on the homepage if (window.location.pathname === '/') { @@ -491,6 +490,9 @@ function noteApp() { this.searchResults = []; this.clearSearchHighlights(); } + } else if (e.state && e.state.mediaPath) { + // Navigating to a media file + this.viewMedia(e.state.mediaPath, null, false); } else { // Navigating back to homepage this.currentNote = ''; @@ -1038,7 +1040,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 +1048,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 +1095,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 @@ -1742,9 +1744,6 @@ function noteApp() { handleFolderClick(el) { this.toggleFolder(el.dataset.path); }, - handleFolderDragStart(el, event) { - this.onFolderDragStart(el.dataset.path, event); - }, handleFolderDragOver(el, event) { event.preventDefault(); this.dragOverFolder = el.dataset.path; @@ -1773,23 +1772,20 @@ function noteApp() { this.deleteFolder(el.dataset.path, el.dataset.name); }, - // Item (note/image) handlers - read from dataset + // Item (note/media) handlers - read from dataset handleItemClick(el) { this.openItem(el.dataset.path, el.dataset.type); }, - handleItemDragStart(el, event) { - this.onNoteDragStart(el.dataset.path, event); - }, 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); } @@ -1817,15 +1813,15 @@ function noteApp() { data-path="${esc(folder.path)}" data-name="${esc(folder.name)}" draggable="true" - ondragstart="window.$root.handleFolderDragStart(this, event)" - ondragend="window.$root.onFolderDragEnd()" + ondragstart="window.$root.onItemDragStart(this.dataset.path, 'folder', event)" + ondragend="window.$root.onItemDragEnd()" ondragover="window.$root.handleFolderDragOver(this, event)" ondragenter="window.$root.handleFolderDragOver(this, event)" ondragleave="window.$root.handleFolderDragLeave(this)" ondrop="window.$root.handleFolderDrop(this, event)" onclick="window.$root.handleFolderClick(this)" - onmouseover="if(!window.$root.draggedNote && !window.$root.draggedFolder) this.style.backgroundColor='var(--bg-hover)'" - onmouseout="if(!window.$root.draggedNote && !window.$root.draggedFolder) this.style.backgroundColor='transparent'" + onmouseover="if(!window.$root.draggedItem) this.style.backgroundColor='var(--bg-hover)'" + onmouseout="if(!window.$root.draggedItem) this.style.backgroundColor='transparent'" class="folder-item px-2 py-1 text-sm relative" style="color: var(--text-primary); cursor: pointer;" > @@ -1838,7 +1834,7 @@ function noteApp() { - + ${esc(folder.name)} ${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `(${this.t('folders.empty')})` : ''} @@ -1893,47 +1889,7 @@ 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'; - const isCurrentNote = this.currentNote === note.path; - const isCurrentImage = this.currentImage === note.path; - const isCurrent = isImage ? isCurrentImage : isCurrentNote; - - // Different icon for images, share icon for shared notes - const isShared = !isImage && this.isNoteShared(note.path); - const shareIcon = isShared ? '' : ''; - const icon = isImage ? '🖼️' : ''; - - html += ` -
- ${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)} - -
- `; + html += this.renderNoteItem(note); }); } @@ -1944,6 +1900,60 @@ function noteApp() { return html; }, + // Render a single note/media item (used by both folders and root level) + renderNoteItem(note) { + const esc = (s) => this.escapeHtmlAttr(s); + const isMediaFile = note.type !== 'note'; + const isCurrentNote = this.currentNote === note.path; + const isCurrentMedia = this.currentMedia === note.path; + const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote; + + // Share icon for shared notes + const isShared = !isMediaFile && this.isNoteShared(note.path); + const shareIcon = isShared ? '' : ''; + const icon = this.getMediaIcon(note.type); + + return ` +
+ ${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)} + +
+ `; + }, + + // Render root-level items (notes and media not in any folder) + renderRootItems() { + const root = this.folderTree['__root__']; + if (!root || !root.notes || root.notes.length === 0) { + return ''; + } + return root.notes.map(note => this.renderNoteItem(note)).join(''); + }, + // Toggle folder expansion toggleFolder(folderPath) { if (this.expandedFolders.has(folderPath)) { @@ -2035,41 +2045,30 @@ function noteApp() { }, 200); // Increased delay to ensure Alpine has finished rendering }, - // Drag and drop handlers - onNoteDragStart(notePath, event) { - // Check if this is an image - const item = this.notes.find(n => n.path === notePath); - const isImage = item && item.type === 'image'; - + // Unified drag and drop handlers for notes, folders, and media + onItemDragStart(itemPath, itemType, event) { // Set unified drag state - this.draggedItem = { - path: notePath, - type: isImage ? 'image' : 'note' - }; + this.draggedItem = { path: itemPath, type: itemType }; - // For notes, also set legacy draggedNote for folder move logic - if (!isImage) { - this.draggedNote = notePath; - // Make drag image semi-transparent - if (event.target) { - event.target.style.opacity = '0.5'; - } + // Make drag image semi-transparent + if (event.target) { + event.target.style.opacity = '0.5'; } event.dataTransfer.effectAllowed = 'all'; }, - onNoteDragEnd() { - this.draggedNote = null; + onItemDragEnd() { this.draggedItem = null; this.dropTarget = null; this.dragOverFolder = null; - // Reset opacity of all note items - document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1'); - // Reset drag-over class (more efficient than querying all folder items) + // Reset opacity of all draggable items + document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1'); + // Reset drag-over class document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); }, + // Handle dragover on editor to show cursor position onEditorDragOver(event) { if (!this.draggedItem) return; @@ -2141,26 +2140,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 +2191,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 +2223,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 +2260,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 +2288,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 +2312,115 @@ 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}`; + + // Expand folder tree to show the media file + this.expandFolderForNote(mediaPath); // 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); } }, @@ -2388,9 +2433,9 @@ function noteApp() { const href = link.getAttribute('href'); if (!href) return; - // Check if it's an external link - if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('mailto:')) { - return; // Let external links work normally + // Check if it's an external link or API path (media files, etc.) + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('/api/')) { + return; // Let external links and API paths work normally } // Prevent default navigation for internal links @@ -2474,28 +2519,9 @@ function noteApp() { } }, - // Folder drag handlers - onFolderDragStart(folderPath, event) { - this.draggedFolder = folderPath; - // Make drag image semi-transparent - if (event && event.target) { - event.target.style.opacity = '0.5'; - } - }, - - onFolderDragEnd() { - this.draggedFolder = null; - this.dropTarget = null; - this.dragOverFolder = null; - // Reset styles - only query elements with drag-over class (more efficient) - document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1'); - document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); - }, cancelDrag() { // Cancel any active drag operation (triggered by ESC key) - this.draggedNote = null; - this.draggedFolder = null; this.draggedItem = null; this.dropTarget = null; this.dragOverFolder = null; @@ -2511,122 +2537,62 @@ function noteApp() { return; } - // IMPORTANT: Capture dragged item info immediately before async operations - // because ondragend may fire and clear these values - const draggedNotePath = this.draggedNote || (this.draggedItem?.type === 'note' ? this.draggedItem.path : null); - const draggedFolderPath = this.draggedFolder; + // Capture dragged item info immediately (ondragend may clear it) + if (!this.draggedItem) return; + const { path: draggedPath, type: draggedType } = this.draggedItem; - // Handle note drop into folder - if (draggedNotePath) { - const note = this.notes.find(n => n.path === draggedNotePath); - if (!note) return; - - // Don't allow moving images to folders - if (note.type === 'image') { - return; - } - - // Get note filename - const filename = note.path.split('/').pop(); - const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; - - if (newPath === draggedNotePath) { - return; - } - - // Check if this note is favorited BEFORE the async call - const wasFavorited = this.favoritesSet.has(draggedNotePath); - - try { - const response = await fetch('/api/notes/move', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - oldPath: draggedNotePath, - newPath: newPath - }) - }); - - if (response.ok) { - // Update favorites if the moved note was favorited - if (wasFavorited) { - // Update Array (create new array for reactivity) - const newFavorites = this.favorites.map(f => f === draggedNotePath ? newPath : f); - this.favorites = newFavorites; - // Recreate Set from array for consistency - this.favoritesSet = new Set(newFavorites); - this.saveFavorites(); - } - - // Keep current note open if it was the moved note - const wasCurrentNote = this.currentNote === draggedNotePath; - - await this.loadNotes(); - - if (wasCurrentNote) { - this.currentNote = newPath; - } - } else { - const errorData = await response.json().catch(() => ({})); - alert(errorData.detail || this.t('move.failed_note')); - } - } catch (error) { - console.error('Failed to move note:', error); - alert(this.t('move.failed_note')); - } - - return; - } + // Determine item category for endpoint selection + const isFolder = draggedType === 'folder'; + const isNote = draggedType === 'note'; + const isMedia = !isFolder && !isNote; // image, audio, video, document - // Handle folder drop into folder - if (draggedFolderPath) { + // Handle folder drop + if (isFolder) { // Prevent dropping folder into itself or its subfolders - if (targetFolderPath === draggedFolderPath || - targetFolderPath.startsWith(draggedFolderPath + '/')) { + if (targetFolderPath === draggedPath || + targetFolderPath.startsWith(draggedPath + '/')) { alert(this.t('folders.cannot_move_into_self')); return; } - const folderName = draggedFolderPath.split('/').pop(); + const folderName = draggedPath.split('/').pop(); const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName; - if (newPath === draggedFolderPath) { - return; - } + if (newPath === draggedPath) return; // Capture favorites info before async call - const oldPrefix = draggedFolderPath + '/'; + const oldPrefix = draggedPath + '/'; const newPrefix = newPath + '/'; try { const response = await fetch('/api/folders/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - oldPath: draggedFolderPath, - newPath: newPath - }) + body: JSON.stringify({ oldPath: draggedPath, newPath }) }); if (response.ok) { - // Update favorites that were in the moved folder - const newFavorites = this.favorites.map(f => { - if (f.startsWith(oldPrefix)) { - return f.replace(oldPrefix, newPrefix); - } - return f; - }); - // Check if anything changed - if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) { + // Update favorites for notes inside moved folder + const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix)); + if (favoritesInFolder.length > 0) { + const newFavorites = this.favorites.map(f => + f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f + ); this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } + // Keep folder expanded if it was + const wasExpanded = this.expandedFolders.has(draggedPath); + await this.loadNotes(); - // Update current note path if it was in the moved folder - if (this.currentNote && this.currentNote.startsWith(oldPrefix)) { - this.currentNote = this.currentNote.replace(oldPrefix, newPrefix); + await this.loadSharedNotePaths(); + + if (wasExpanded) { + this.expandedFolders.delete(draggedPath); + this.expandedFolders.add(newPath); + this.saveExpandedFolders(); } } else { const errorData = await response.json().catch(() => ({})); @@ -2636,10 +2602,63 @@ function noteApp() { console.error('Failed to move folder:', error); alert(this.t('move.failed_folder')); } - this.dropTarget = null; + return; + } + + // Handle note or media drop into folder + const item = this.notes.find(n => n.path === draggedPath); + if (!item) return; + + const filename = draggedPath.split('/').pop(); + const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; + + if (newPath === draggedPath) return; + + // Check if note is favorited (only for notes) + const wasFavorited = isNote && this.favoritesSet.has(draggedPath); + + try { + // Use different endpoint for media vs notes + const endpoint = isMedia ? '/api/media/move' : '/api/notes/move'; + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPath: draggedPath, newPath }) + }); + + if (response.ok) { + // Update favorites if the moved note was favorited + if (wasFavorited) { + const newFavorites = this.favorites.map(f => f === draggedPath ? newPath : f); + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + + // Keep current item open if it was the moved one + const wasCurrentNote = this.currentNote === draggedPath; + const wasCurrentMedia = this.currentMedia === draggedPath; + + await this.loadNotes(); + if (isNote) { + await this.loadSharedNotePaths(); + } + + if (wasCurrentNote) this.currentNote = newPath; + if (wasCurrentMedia) this.currentMedia = newPath; + } else { + const errorData = await response.json().catch(() => ({})); + const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; + alert(errorData.detail || this.t(errorKey)); + } + } catch (error) { + console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error); + const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; + alert(this.t(errorKey)); } }, + // Load a specific note async loadNote(notePath, updateHistory = true, searchQuery = '') { try { @@ -2655,7 +2674,7 @@ function noteApp() { window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); this.currentNote = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; document.title = this.appName; return; } @@ -2667,9 +2686,10 @@ function noteApp() { this.currentNote = notePath; this._lastRenderedContent = ''; // Clear render cache for new note this._cachedRenderedHTML = ''; + this._initializedVideoSources = new Set(); // Clear video cache for new note 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 @@ -2752,9 +2772,9 @@ function noteApp() { } }, - // Load note from URL path - loadNoteFromURL() { - // Get path from URL (e.g., /folder/note or /note) + // Load item (note or media) from URL path + loadItemFromURL() { + // Get path from URL (e.g., /folder/note or /folder/image.png) let path = window.location.pathname; // Strip .md extension if present (for MKdocs/Zensical integration) @@ -2772,12 +2792,12 @@ function noteApp() { // Remove leading slash and decode URL encoding (e.g., %20 -> space) const decodedPath = decodeURIComponent(path.substring(1)); - // Check if this is an image path (check if it exists in notes list as an image) + // Check if this is a media file (image, audio, video, PDF) const matchedItem = this.notes.find(n => n.path === decodedPath); - if (matchedItem && matchedItem.type === 'image') { - // It's an image, view it - this.viewImage(decodedPath, false); // false = don't update history (we're already at this URL) + if (matchedItem && matchedItem.type !== 'note') { + // It's a media file, view it + this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history } else { // It's a note, add .md extension and load it const notePath = decodedPath + '.md'; @@ -3944,20 +3964,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 +3986,28 @@ 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': + // Local PDFs: show iframe preview + 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 +4081,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 - // Skip external URLs and already-transformed paths - if (!src.startsWith('http://') && !src.startsWith('https://') && - !src.startsWith('//') && !src.startsWith('/api/images/') && - !src.startsWith('data:')) { + const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'); + const isLocal = !isExternal && !src.startsWith('data:'); + + // Transform relative paths to /api/media/ for serving + if (isLocal && !src.startsWith('/api/media/')) { // 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 +4099,56 @@ 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, '"'); + + // Only convert LOCAL media to embedded elements (security) + // External non-image media gets styled links instead + if (isLocal || src.startsWith('/api/media/')) { + 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') { + // Local PDFs: show iframe preview + const wrapper = document.createElement('div'); + wrapper.className = 'media-embed media-pdf'; + wrapper.innerHTML = ``; + img.replaceWith(wrapper); + return; + } + } else if (isExternal && mediaType === 'document') { + // External PDFs: styled link (opens in new tab) + const link = document.createElement('a'); + link.href = src; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.className = 'pdf-link'; + link.title = `Open ${safeAlt}`; + link.innerHTML = `📄 ${safeAlt}Opens in new tab`; + img.replaceWith(link); + return; + } + // External audio/video: leave as broken image for security } + // 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); } }); @@ -4103,6 +4182,17 @@ function noteApp() { this.addCopyButtonToCodeBlock(pre); } }); + + // Enable video metadata loading (for first frame preview) + // Track by source URL to prevent duplicate requests on re-renders + if (!this._initializedVideoSources) this._initializedVideoSources = new Set(); + previewEl.querySelectorAll('video[preload="none"]').forEach((video) => { + const src = video.getAttribute('src'); + if (src && !this._initializedVideoSources.has(src)) { + this._initializedVideoSources.add(src); + video.preload = 'metadata'; + } + }); } }, 0); @@ -4884,15 +4974,57 @@ function noteApp() { // Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters) let renderedHTML = this.renderedMarkdown; + // Convert non-image media (audio, video, PDF) to placeholders first + // These shouldn't be embedded as base64 (too large) + // Use CSS variables with fallbacks for theme-aware styling (matches backend export.py) + const mediaPlaceholder = (type, name) => { + const icons = { audio: '🎵', video: '🎬', document: '📄' }; + const labels = { audio: 'Audio file', video: 'Video file', document: 'PDF document' }; + const icon = icons[type] || '📎'; + const label = labels[type] || 'Media file'; + return `
+${icon} +
+
${name}
+
${label} — not available in exported view
+
+
`; + }; + + // Replace audio embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('audio', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + + // Replace video embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('video', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + + // Replace PDF embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('document', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + // Embed local images as base64 for fully self-contained HTML - const imgRegex = /src="\/api\/images\/([^"]+)"/g; + // Handle both /api/media/ and legacy /api/images/ paths + const imgRegex = /src="\/api\/(?:media|images)\/([^"]+)"/g; const imgMatches = [...renderedHTML.matchAll(imgRegex)]; for (const match of imgMatches) { const encodedPath = match[1]; + // Skip non-image files (already handled above) + const ext = encodedPath.split('.').pop().toLowerCase(); + if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { + continue; + } + 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 +5549,7 @@ function noteApp() { this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; this.outline = []; document.title = this.appName; @@ -5439,7 +5571,7 @@ function noteApp() { this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; - this.currentImage = ''; + this.currentMedia = ''; this.outline = []; this.mobileSidebarOpen = false; document.title = this.appName; @@ -5462,7 +5594,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..e9f2a9b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -444,6 +444,92 @@ 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 (iframe) */ + .markdown-preview .media-pdf { + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + overflow: hidden; + background: var(--bg-secondary); + } + .markdown-preview .media-pdf iframe { + width: 100%; + height: 600px; + border: none; + display: block; + } + + /* PDF links (for external PDFs) */ + .markdown-preview .pdf-link { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem 1.25rem; + margin: 1rem 0; + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + color: var(--text-primary); + text-decoration: none; + transition: all 0.15s ease; + } + .markdown-preview .pdf-link:hover { + background: var(--bg-secondary); + border-color: var(--accent-primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + .markdown-preview .pdf-link:hover .pdf-link-content { + color: var(--accent-primary); + } + .markdown-preview .pdf-link-content { + font-weight: 600; + color: var(--text-primary); + } + .markdown-preview .pdf-link-note { + font-size: 0.75rem; + color: var(--text-secondary); + } + /* Task lists */ .markdown-preview input[type="checkbox"] { margin-right: 0.5rem; @@ -849,6 +935,12 @@ font-size: 10px; } + /* Zen mode mobile adjustments - use 95% width instead of fixed padding */ + .zen-mode-active .zen-editor-container { + padding: 1rem 2.5%; + width: 100%; + } + } @media (min-width: 769px) { @@ -1377,7 +1469,7 @@ - +
@@ -1443,34 +1535,8 @@
- - + +
@@ -1646,7 +1712,7 @@ onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'" onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'" > - +
@@ -1684,6 +1750,7 @@ class="block truncate" :style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')" x-text="heading.text" + :title="heading.text" > @@ -1868,7 +1935,7 @@
-