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: 
- - 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''
- # 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: 
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''
+ return f''
# 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 `
`;
+ const mediaSrc = `/api/media/${encodedPath}`;
+ const mediaType = self.getMediaType(filename);
+
+ // Return appropriate HTML based on media type
+ switch (mediaType) {
+ case 'audio':
+ return ``;
+ case 'video':
+ return ``;
+ case 'document':
+ // Local PDFs: show iframe preview
+ return ``;
+ default: // image
+ return `
`;
+ }
}
- // 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(
+ /
@@ -1443,34 +1535,8 @@
-
-
-
-
+
+
@@ -1550,8 +1616,8 @@
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'"
>
-
-
+
+
@@ -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 @@
-
+
-
+
@@ -2101,7 +2168,7 @@