From 88302fecd34512a04c9c44262d8113e205149ddd Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 18 Jan 2026 16:00:01 +0100 Subject: [PATCH 01/10] support for more media types --- Dockerfile | 2 +- backend/export.py | 185 ++++++++++++++++------- backend/main.py | 84 +++++++---- backend/utils.py | 66 ++++++--- documentation/API.md | 53 ++++--- documentation/FEATURES.md | 10 +- frontend/app.js | 305 ++++++++++++++++++++++++-------------- frontend/index.html | 145 ++++++++++++++---- locales/de-DE.json | 10 +- locales/en-GB.json | 10 +- locales/en-US.json | 10 +- locales/es-ES.json | 10 +- locales/fr-FR.json | 10 +- locales/it-IT.json | 10 +- locales/ja-JP.json | 10 +- locales/ru-RU.json | 10 +- locales/sl-SI.json | 10 +- locales/zh-CN.json | 10 +- run.py | 3 +- 19 files changed, 631 insertions(+), 322 deletions(-) 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'" > - +
- From 1751e84dd38a7a650121eb87e2f2d1dfbe9ab7bd Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 18 Jan 2026 18:33:56 +0100 Subject: [PATCH 04/10] fixed comments --- backend/export.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/export.py b/backend/export.py index 04c8836..e5eb995 100644 --- a/backend/export.py +++ b/backend/export.py @@ -1,7 +1,10 @@ """ HTML Export Module for NoteDiscovery -Generates standalone HTML files for notes with embedded media and styling. -Used by both /api/export (download) and /public (sharing) endpoints. +Generates standalone HTML files for notes with embedded images and styling. +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 @@ -161,15 +164,17 @@ def generate_media_placeholder(media_type: str, alt_text: str) -> str: ''' -def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: +def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: """ - Find all media 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 media: ![[file.png]] or ![[file.mp3|alt text]] - For images: returns markdown ![alt](base64) - For audio/video/PDF: returns inline HTML + 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 media: ![[file.png]] or ![[file.mp3|alt text]] @@ -263,9 +268,11 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P 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: - return embed_media_as_base64(markdown_content, note_folder, notes_dir) + """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: From 37d7c0eba9dac85b5d9489d2e7735a44819a8712 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 18 Jan 2026 19:04:05 +0100 Subject: [PATCH 05/10] open local PDFs in iframes, externals as links... --- backend/export.py | 19 ++++++++++-- frontend/app.js | 70 +++++++++++++++++++++++++++++---------------- frontend/index.html | 37 ++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/backend/export.py b/backend/export.py index e5eb995..d924658 100644 --- a/backend/export.py +++ b/backend/export.py @@ -211,8 +211,23 @@ def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir alt_text = match.group(1) media_path = match.group(2) - # Skip external URLs and already-embedded base64 - if media_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) diff --git a/frontend/app.js b/frontend/app.js index 6fb92ca..c7aed86 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2432,9 +2432,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 @@ -2606,6 +2606,7 @@ function noteApp() { const wasCurrentNote = this.currentNote === draggedNotePath; await this.loadNotes(); + await this.loadSharedNotePaths(); // Refresh shared paths after move if (wasCurrentNote) { this.currentNote = newPath; @@ -2668,6 +2669,7 @@ function noteApp() { } await this.loadNotes(); + await this.loadSharedNotePaths(); // Refresh shared paths after folder move // 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); @@ -4021,7 +4023,8 @@ function noteApp() { case 'video': return `
`; case 'document': - return `
`; + // Local PDFs: show iframe preview + return `
`; default: // image return `${safeAlt}`; } @@ -4110,11 +4113,11 @@ function noteApp() { images.forEach(img => { let src = img.getAttribute('src'); if (src) { + const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'); + const isLocal = !isExternal && !src.startsWith('data:'); + // 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/media/') && - !src.startsWith('data:')) { + if (isLocal && !src.startsWith('/api/media/')) { // URL-encode path segments to handle spaces and special characters const encodedPath = src.split('/').map(segment => { try { @@ -4132,25 +4135,42 @@ function noteApp() { 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); + // 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 diff --git a/frontend/index.html b/frontend/index.html index 3995c61..1f87c21 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -484,17 +484,50 @@ border-radius: 0; } - /* PDF embeds */ + /* 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; - background: #525659; + 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 */ From 27b339c18568838cd356461717a1e157147332c8 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 18 Jan 2026 19:08:56 +0100 Subject: [PATCH 06/10] minor refactor, doc fixing --- backend/export.py | 19 ++----------------- documentation/API.md | 4 ++-- documentation/FEATURES.md | 5 +++-- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/backend/export.py b/backend/export.py index d924658..1ef7095 100644 --- a/backend/export.py +++ b/backend/export.py @@ -13,23 +13,8 @@ from pathlib import Path from typing import Optional, Tuple import mimetypes - -# Media type detection -MEDIA_EXTENSIONS = { - 'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'}, - 'audio': {'.mp3', '.wav', '.ogg', '.m4a'}, - 'video': {'.mp4', '.webm'}, - 'document': {'.pdf'} -} - - -def get_media_type(filename: str) -> Optional[str]: - """Determine media type based on file extension.""" - ext = Path(filename).suffix.lower() - for media_type, extensions in MEDIA_EXTENSIONS.items(): - if ext in extensions: - return media_type - return None +# Import shared media type definitions from utils to avoid duplication +from backend.utils import MEDIA_EXTENSIONS, get_media_type def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]: diff --git a/documentation/API.md b/documentation/API.md index 2c08106..a324c66 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -114,8 +114,8 @@ Upload a media file to the `_attachments` directory. Files are automatically org | Type | Formats | Max Size | |------|---------|----------| | Images | JPG, PNG, GIF, WebP | 10 MB | -| Audio | MP3, WAV, OGG | 50 MB | -| Video | MP4, WebM | 100 MB | +| Audio | MP3, WAV, OGG, M4A | 50 MB | +| Video | MP4, WebM, MOV, AVI | 100 MB | | Documents | PDF | 20 MB | **Response:** diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 074fa1b..1209d35 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -19,10 +19,11 @@ - **Drag & drop upload** - Drop files from your file system directly into the editor - **Clipboard paste** - Paste images from clipboard with Ctrl+V - **Images** - JPG, PNG, GIF, WebP (max 10MB) -- **Audio** - MP3, WAV, OGG (max 50MB) -- **Video** - MP4, WebM (max 100MB) +- **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 From 12f6f3bdc3c8218ffdf2be4a7bb99e524471a23a Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Mon, 19 Jan 2026 09:42:32 +0100 Subject: [PATCH 07/10] finally! move all items anywhere --- backend/main.py | 50 +++++++++ backend/utils.py | 41 +++---- documentation/API.md | 22 ++++ frontend/app.js | 249 ++++++++++++++++++------------------------- frontend/index.html | 30 +++--- locales/de-DE.json | 4 +- locales/en-GB.json | 3 +- locales/en-US.json | 4 +- locales/es-ES.json | 4 +- locales/fr-FR.json | 4 +- locales/it-IT.json | 3 +- locales/ja-JP.json | 3 +- locales/ru-RU.json | 3 +- locales/sl-SI.json | 3 +- locales/zh-CN.json | 3 +- 15 files changed, 236 insertions(+), 190 deletions(-) diff --git a/backend/main.py b/backend/main.py index 828793e..b7e3c18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -584,6 +584,56 @@ async def upload_media(request: Request, file: UploadFile = File(...), note_path 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"]) @limiter.limit("30/minute") async def move_note_endpoint(request: Request, data: dict): diff --git a/backend/utils.py b/backend/utils.py index ae4af02..5703607 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -512,34 +512,35 @@ def get_media_type(filename: str) -> Optional[str]: def get_all_images(notes_dir: str) -> List[Dict]: """ - Get all media files (images, audio, video, documents) from attachments directories. + 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. """ media_files = [] notes_path = Path(notes_dir) - # 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 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' - }) + # 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 media_files diff --git a/documentation/API.md b/documentation/API.md index a324c66..27c32dd 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -140,6 +140,28 @@ curl -X POST http://localhost:8000/api/upload-media \ 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:** - Media is stored in `_attachments` folders relative to the note's location - Filenames are automatically timestamped (e.g., `media-20240417093343.mp3`) diff --git a/frontend/app.js b/frontend/app.js index c7aed86..610cd53 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 @@ -444,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(); } }); @@ -1744,7 +1742,7 @@ function noteApp() { this.toggleFolder(el.dataset.path); }, handleFolderDragStart(el, event) { - this.onFolderDragStart(el.dataset.path, event); + this.onItemDragStart(el.dataset.path, 'folder', event); }, handleFolderDragOver(el, event) { event.preventDefault(); @@ -1779,7 +1777,7 @@ function noteApp() { this.openItem(el.dataset.path, el.dataset.type); }, handleItemDragStart(el, event) { - this.onNoteDragStart(el.dataset.path, event); + this.onItemDragStart(el.dataset.path, el.dataset.type || 'note', event); }, handleItemHover(el, isEnter) { const path = el.dataset.path; @@ -1819,14 +1817,14 @@ function noteApp() { data-name="${esc(folder.name)}" draggable="true" ondragstart="window.$root.handleFolderDragStart(this, event)" - ondragend="window.$root.onFolderDragEnd()" + 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;" > @@ -1912,7 +1910,7 @@ function noteApp() { data-type="${note.type}" draggable="true" ondragstart="window.$root.handleItemDragStart(this, event)" - ondragend="window.$root.onNoteDragEnd()" + ondragend="window.$root.onItemDragEnd()" onclick="window.$root.handleItemClick(this)" class="note-item px-2 py-1 text-sm relative" style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isMediaFile ? 'opacity: 0.85;' : ''} cursor: pointer;" @@ -2036,41 +2034,30 @@ function noteApp() { }, 200); // Increased delay to ensure Alpine has finished rendering }, - // Drag and drop handlers - onNoteDragStart(notePath, event) { - // Check if this is a media file - const item = this.notes.find(n => n.path === notePath); - const isMediaFile = item && item.type !== 'note'; - + // Unified drag and drop handlers for notes, folders, and media + onItemDragStart(itemPath, itemType, event) { // Set unified drag state - this.draggedItem = { - path: notePath, - type: item ? item.type : 'note' - }; + this.draggedItem = { path: itemPath, type: itemType }; - // For notes, also set legacy draggedNote for folder move logic - if (!isMediaFile) { - 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; @@ -2518,28 +2505,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; @@ -2555,124 +2523,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(); - await this.loadSharedNotePaths(); // Refresh shared paths after move - - 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(); - await this.loadSharedNotePaths(); // Refresh shared paths after folder move - // 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(() => ({})); @@ -2682,10 +2588,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 { diff --git a/frontend/index.html b/frontend/index.html index 1f87c21..025701b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1503,24 +1503,24 @@
- - + +
@@ -1533,11 +1533,11 @@ - - + +