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'''
'''
+ elif media_type == 'video':
+ return f'''
+
+
'''
+ elif media_type == 'document':
+ return f'''
+
+
'''
+ else: # image
+ return f''
+
+
+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: 
- - Wikilink images: ![[image.png]] or ![[image.png|alt text]]
+ - Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
+
+ For images: returns markdown 
+ 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''
+ 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: 
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''
+ 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'}"
>
-
+
-
+
@@ -2101,7 +2154,7 @@