Merge pull request #155 from gamosoft/features/more-media-support

Features/more media support
This commit is contained in:
Guillermo Villar 2026-01-19 11:19:18 +01:00 committed by GitHub
commit 9496c4fe57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1036 additions and 563 deletions

View File

@ -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

View File

@ -1,33 +1,56 @@
"""
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
@ -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('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
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'''<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
<span style="font-size:2rem;">{icon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary,#212529);">{safe_alt}</div>
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">{label} not available in exported view</div>
</div>
</div>'''
def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""
Find all image references in markdown and embed them as base64.
Process all media references in markdown for standalone HTML export.
Handles:
- Standard markdown images: ![alt](path)
- Wikilink images: ![[image.png]] or ![[image.png|alt text]]
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
Behavior by media type:
- Images (jpg, png, gif, webp): Embedded as base64 data URLs
- Audio/Video/PDF: Replaced with styled placeholder HTML (not embedded - too large)
"""
# First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]]
wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
# First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink_image(match):
image_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0]
def replace_wikilink_media(match):
media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
# Find the image
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
# Check media type first
media_type = get_media_type(media_name)
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, alt_text)
# For images, embed as base64
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if resolved_path:
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f'![{alt_text}]({base64_url})'
# Image not found, convert to placeholder
return f'![{alt_text}]()'
# Image not found
return f'<span style="color:var(--text-tertiary,#999);opacity:0.7;" title="Image not found">🖼️ {alt_text}</span>'
markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content)
markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
# Then, handle standard markdown images: ![alt](path)
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
def replace_image(match):
def replace_media(match):
alt_text = match.group(1)
img_path = match.group(2)
media_path = match.group(2)
# Skip external URLs and already-embedded base64
if img_path.startswith(('http://', 'https://', 'data:')):
# Handle external URLs
if media_path.startswith(('http://', 'https://')):
# Check if it's a PDF - generate styled external link
media_type = get_media_type(media_path)
if media_type == 'document':
display_name = alt_text or Path(media_path).stem
safe_name = display_name.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
safe_url = media_path.replace('"', '&quot;')
return f'''<a href="{safe_url}" target="_blank" rel="noopener noreferrer" style="display:flex;flex-direction:column;gap:0.25rem;padding:1rem 1.25rem;margin:1rem 0;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;color:var(--text-primary,#212529);text-decoration:none;">
<span style="font-weight:600;">📄 {safe_name}</span>
<span style="font-size:0.75rem;color:var(--text-secondary,#6c757d);">Opens in new tab</span>
</a>'''
# Other external media: keep as-is (will show as broken image)
return match.group(0)
# Skip already-embedded base64
if media_path.startswith('data:'):
return match.group(0)
# Skip empty paths (from failed wikilink conversion)
if not img_path:
if not media_path:
return match.group(0)
# Handle /api/images/ paths (convert to filesystem paths)
if img_path.startswith('/api/images/'):
# Strip the /api/images/ prefix to get the relative path within notes_dir
relative_path = img_path[len('/api/images/'):]
# Check media type first
media_type = get_media_type(media_path)
display_alt = alt_text or Path(media_path).stem
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, display_alt)
# For images, proceed with base64 embedding
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if media_path.startswith('/api/media/'):
relative_path = media_path[len('/api/media/'):]
resolved_path = (notes_dir / relative_path).resolve()
elif media_path.startswith('/api/images/'):
# Legacy path support for backward compatibility
relative_path = media_path[len('/api/images/'):]
resolved_path = (notes_dir / relative_path).resolve()
else:
# Try to resolve the image path relative to note folder
resolved_path = (note_folder / img_path).resolve()
# Try to resolve the media path relative to note folder
resolved_path = (note_folder / media_path).resolve()
# If not found, try the attachment search
if not resolved_path.exists():
# Extract just the filename and search
image_name = Path(img_path).name
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
media_name = Path(media_path).name
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if not resolved_path:
return match.group(0) # Keep original if not found
@ -170,19 +255,26 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir:
# Path is outside notes_dir, skip
return match.group(0)
# Get base64 data
# Get base64 data for image
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f'![{alt_text}]({base64_url})'
return f'![{display_alt}]({base64_url})'
# Image not found, keep original
return match.group(0)
markdown_content = re.sub(img_pattern, replace_image, markdown_content)
markdown_content = re.sub(img_pattern, replace_media, markdown_content)
return markdown_content
# Legacy alias for backward compatibility
# Legacy alias for backward compatibility
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""Alias for process_media_for_export (legacy name kept for compatibility)."""
return process_media_for_export(markdown_content, note_folder, notes_dir)
def convert_wikilinks_to_html(markdown_content: str) -> str:
"""
Convert wikilinks [[note]] or [[note|display text]] to HTML links.

View File

@ -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"])

View File

@ -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()
# 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()
images.append({
"name": image_file.name,
media_files.append({
"name": media_file.name,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.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": "image"
"type": media_type # 'image', 'audio', 'video', or 'document'
})
return images
return media_files
def parse_tags(content: str) -> List[str]:

View File

@ -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: <image file>
file: <media file>
note_path: <path of note to attach to>
```
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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -444,6 +444,92 @@
margin: 1rem 0;
}
/* Embedded Media (audio, video, PDF) */
.markdown-preview .media-embed {
margin: 1.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
}
/* Audio embeds */
.markdown-preview .media-audio {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-primary);
}
.markdown-preview .media-audio audio {
width: 100%;
max-width: 500px;
border-radius: 2rem;
}
.markdown-preview .media-audio .media-caption {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Video embeds */
.markdown-preview .media-video {
background: #000;
display: flex;
justify-content: center;
}
.markdown-preview .media-video video {
width: 100%;
max-width: 800px;
max-height: 450px;
border-radius: 0;
}
/* PDF embeds (iframe) */
.markdown-preview .media-pdf {
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
overflow: hidden;
background: var(--bg-secondary);
}
.markdown-preview .media-pdf iframe {
width: 100%;
height: 600px;
border: none;
display: block;
}
/* PDF links (for external PDFs) */
.markdown-preview .pdf-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 1.25rem;
margin: 1rem 0;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.15s ease;
}
.markdown-preview .pdf-link:hover {
background: var(--bg-secondary);
border-color: var(--accent-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.markdown-preview .pdf-link:hover .pdf-link-content {
color: var(--accent-primary);
}
.markdown-preview .pdf-link-content {
font-weight: 600;
color: var(--text-primary);
}
.markdown-preview .pdf-link-note {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Task lists */
.markdown-preview input[type="checkbox"] {
margin-right: 0.5rem;
@ -849,6 +935,12 @@
font-size: 10px;
}
/* Zen mode mobile adjustments - use 95% width instead of fixed padding */
.zen-mode-active .zen-editor-container {
padding: 1rem 2.5%;
width: 100%;
}
}
@media (min-width: 769px) {
@ -1377,7 +1469,7 @@
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs truncate" x-text="fav.name"></span>
<span class="text-xs truncate" x-text="fav.name" :title="fav.name"></span>
</div>
<button
@click.stop="toggleFavorite(fav.path)"
@ -1417,24 +1509,24 @@
<div
class="text-xs px-2 py-1 mb-2 rounded transition-all font-medium text-center"
:class="{
'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '',
'border-2 border-dashed': (draggedNote || draggedFolder) && dragOverFolder !== '',
'border-2 border-transparent': !draggedNote && !draggedFolder
'border-2 border-dashed bg-accent-light': draggedItem && dragOverFolder === '',
'border-2 border-dashed': draggedItem && dragOverFolder !== '',
'border-2 border-transparent': !draggedItem
}"
:style="{
'color': (draggedNote || draggedFolder) ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)',
'opacity': (draggedNote || draggedFolder) ? '1' : '0.8',
'border-color': (draggedNote || draggedFolder) && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)',
'color': draggedItem ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)',
'opacity': draggedItem ? '1' : '0.8',
'border-color': draggedItem && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)',
'background-color': dragOverFolder === '' ? 'var(--accent-light)' : ''
}"
@dragover.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';"
@dragenter.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';"
@dragleave="if(draggedNote || draggedFolder) dragOverFolder = null;"
@drop="if(draggedNote || draggedFolder) onFolderDrop('')"
@dragover.prevent="if(draggedItem) dragOverFolder = '';"
@dragenter.prevent="if(draggedItem) dragOverFolder = '';"
@dragleave="if(draggedItem) dragOverFolder = null;"
@drop="if(draggedItem) onFolderDrop('')"
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
>
<span x-show="!draggedNote && !draggedFolder && !draggedItem" x-text="t('sidebar.drag_hint')"></span>
<span x-show="draggedNote || draggedFolder" x-text="t('sidebar.root_folder')"></span>
<span x-show="!draggedItem" x-text="t('sidebar.drag_hint')"></span>
<span x-show="draggedItem" x-text="t('sidebar.root_folder')"></span>
</div>
</div>
@ -1443,34 +1535,8 @@
<div x-html="renderFolderRecursive(folder, 0, true)"></div>
</template>
<!-- Root notes and images (no folder) - shown after folders -->
<template x-for="note in (folderTree.__root__ && folderTree.__root__.notes ? folderTree.__root__.notes : [])" :key="note.path">
<div
draggable="true"
@dragstart="onNoteDragStart(note.path, $event)"
@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'"
>
<span class="truncate" style="display: block; padding-right: 30px;">
<template x-if="note.type === 'image'">🖼️ </template>
<span x-text="note.name"></span>
</span>
<button
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
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="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</template>
<!-- Root notes and images (no folder) - rendered via JS for consistency -->
<div x-html="renderRootItems()"></div>
<!-- Empty state -->
<template x-if="notes.length === 0 && allFolders.length === 0">
@ -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'"
>
<div class="font-medium truncate" x-text="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')"></div>
<div class="font-medium truncate" x-text="note.name" :title="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')" :title="note.folder || 'Root'"></div>
</div>
</template>
</div>
@ -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'"
>
<span class="truncate block" x-text="note.name"></span>
<span class="truncate block" x-text="note.name" :title="note.name"></span>
</div>
</template>
<div x-show="searchResults.length === 0" class="px-3 py-4 text-center text-xs" style="color: var(--text-tertiary);" x-text="t('tags.no_matches')">
@ -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"
></span>
</button>
</template>
@ -1868,7 +1935,7 @@
</button>
</div>
<template x-if="!currentNote && !currentImage">
<template x-if="!currentNote && !currentMedia">
<!-- Notes Homepage -->
<div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);">
<!-- Mobile Menu Button (Homepage) -->
@ -2044,12 +2111,12 @@
>
<!-- Delete Button -->
<button
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
@click.stop="note.type !== 'note' ? deleteMedia(note.path) : deleteNote(note.path, note.name)"
class="card-delete-btn hidden sm:block"
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
:title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
:title="note.type !== 'note' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2080,7 +2147,7 @@
</div>
</template>
<template x-if="currentNote || currentImage">
<template x-if="currentNote || currentMedia">
<!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
<!-- Toolbar -->
@ -2101,7 +2168,7 @@
</button>
<input
type="text"
:value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')"
:value="currentNote ? currentNoteName : (currentMedia ? currentMedia.split('/').pop() : '')"
@input="if (currentNote) currentNoteName = $event.target.value"
@blur="if (currentNote) renameNote()"
:readonly="!currentNote"
@ -2140,12 +2207,12 @@
<!-- Delete Button -->
<button
@click="currentImage ? deleteImage(currentImage) : deleteCurrentNote()"
@click="currentMedia ? deleteMedia(currentMedia) : deleteCurrentNote()"
class="p-2 rounded-lg"
style="color: var(--error);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'"
:title="currentImage ? t('toolbar.delete_image') : t('toolbar.delete_note')"
:title="currentMedia ? t('toolbar.delete_image') : t('toolbar.delete_note')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2287,7 +2354,7 @@
<div class="flex-1 flex relative" style="min-height: 0;">
<!-- Editor -->
<div
x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')"
x-show="!currentMedia && (viewMode === 'edit' || viewMode === 'split')"
class="flex flex-col"
style="min-height: 0;"
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
@ -2366,7 +2433,7 @@
<!-- Split view resize handle -->
<div
x-show="!currentImage && viewMode === 'split'"
x-show="!currentMedia && viewMode === 'split'"
@mousedown="startSplitResize($event)"
class="split-resize-handle"
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
@ -2376,7 +2443,7 @@
<!-- Preview -->
<div
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage"
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentMedia"
class="overflow-y-auto overflow-x-hidden custom-scrollbar"
style="background-color: var(--bg-primary); min-height: 0;"
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
@ -2499,17 +2566,55 @@
></div>
</div>
<!-- Image Viewer -->
<template x-if="currentImage">
<!-- Media Viewer (images, audio, video, PDF) -->
<template x-if="currentMedia">
<div
class="flex-1 flex items-center justify-center overflow-auto"
style="background-color: var(--bg-primary); min-height: 0;"
>
<!-- Image -->
<template x-if="currentMediaType === 'image'">
<img
:src="`/api/images/${currentImage}`"
:alt="currentImage.split('/').pop()"
:src="`/api/media/${currentMedia}`"
:alt="currentMedia.split('/').pop()"
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
/>
</template>
<!-- Audio -->
<template x-if="currentMediaType === 'audio'">
<div class="flex flex-col items-center gap-4 p-8">
<div class="text-6xl">🎵</div>
<div class="text-lg font-medium" style="color: var(--text-primary);" x-text="currentMedia.split('/').pop()"></div>
<audio
controls
:src="`/api/media/${currentMedia}`"
style="width: 100%; max-width: 500px;"
>
Your browser does not support the audio element.
</audio>
</div>
</template>
<!-- Video -->
<template x-if="currentMediaType === 'video'">
<video
controls
:src="`/api/media/${currentMedia}`"
style="max-width: 100%; max-height: 100%; padding: 1rem;"
>
Your browser does not support the video element.
</video>
</template>
<!-- PDF -->
<template x-if="currentMediaType === 'document'">
<iframe
:src="`/api/media/${currentMedia}`"
style="width: 100%; height: 100%; border: none;"
:title="currentMedia.split('/').pop()"
></iframe>
</template>
</div>
</template>
</div>
@ -2836,8 +2941,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<div class="flex-1 min-w-0">
<div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder"></div>
<div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name" :title="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder" :title="note.folder"></div>
</div>
</div>
</template>
@ -2958,18 +3063,18 @@
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
<button
class="mobile-bottom-tab"
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentImage && !showGraph))}"
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentMedia && !showGraph))}"
@click="mobileFilesTabClick()"
:aria-label="t('sidebar.files')"
>
<!-- Show home icon when viewing content, folder icon when on homepage -->
<svg x-show="currentNote || currentImage || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<svg x-show="currentNote || currentMedia || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
<svg x-show="!currentNote && !currentImage && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<svg x-show="!currentNote && !currentMedia && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
<span x-text="(currentNote || currentImage || showGraph) ? t('homepage.title') : t('sidebar.files')"></span>
<span x-text="(currentNote || currentMedia || showGraph) ? t('homepage.title') : t('sidebar.files')"></span>
</button>
<button
class="mobile-bottom-tab relative"

View File

@ -44,6 +44,7 @@
"no_notes": "Du hast noch keine Notizen. Erstelle deine erste!",
"no_notes_yet": "Noch keine Notizen oder Ordner.",
"create_first": "Erstelle deine erste Notiz oder Ordner!",
"drop_to_root": "Hier ablegen für Stammverzeichnis",
"no_favorites": "Noch keine Favoriten",
"no_results": "Keine Ergebnisse gefunden",
"expand_all": "Alle Ordner aufklappen",
@ -194,16 +195,17 @@
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
},
"images": {
"confirm_delete": "Bild \"{{name}}\" löschen?",
"upload_failed": "Bild-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Bilddateien gefunden. Unterstützte Formate: JPG, PNG, GIF, WEBP",
"formats": "Unterstützt JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "\"{{name}}\" löschen?",
"upload_failed": "Datei-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Dateien gefunden. Unterstützt: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Verschieben der Notiz fehlgeschlagen.",
"failed_folder": "Verschieben des Ordners fehlgeschlagen."
"failed_folder": "Verschieben des Ordners fehlgeschlagen.",
"failed_media": "Verschieben der Mediendatei fehlgeschlagen."
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Incorrect password. Please try again."
},
"images": {
"confirm_delete": "Delete image \"{{name}}\"?",
"upload_failed": "Failed to upload image",
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP",
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload file",
"no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Failed to move note.",
"failed_folder": "Failed to move folder."
"failed_folder": "Failed to move folder.",
"failed_media": "Failed to move media file."
},
"search": {

View File

@ -44,6 +44,7 @@
"no_notes": "No notes yet. Create your first note!",
"no_notes_yet": "No notes or folders yet.",
"create_first": "Create your first note or folder!",
"drop_to_root": "Drop here for root",
"no_favorites": "No favorites yet",
"no_results": "No results found",
"expand_all": "Expand all folders",
@ -194,16 +195,17 @@
"error_incorrect_password": "Incorrect password. Please try again."
},
"images": {
"confirm_delete": "Delete image \"{{name}}\"?",
"upload_failed": "Failed to upload image",
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP",
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload file",
"no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Failed to move note.",
"failed_folder": "Failed to move folder."
"failed_folder": "Failed to move folder.",
"failed_media": "Failed to move media file."
},
"search": {

View File

@ -44,6 +44,7 @@
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
"no_notes_yet": "Aún no hay notas ni carpetas.",
"create_first": "¡Crea tu primera nota o carpeta!",
"drop_to_root": "Soltar aquí para raíz",
"no_favorites": "Aún no hay favoritos",
"no_results": "No se encontraron resultados",
"expand_all": "Expandir todas las carpetas",
@ -194,16 +195,17 @@
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
},
"images": {
"confirm_delete": "¿Eliminar imagen \"{{name}}\"?",
"upload_failed": "Error al subir imagen",
"no_valid_files": "No se encontraron archivos de imagen válidos. Formatos soportados: JPG, PNG, GIF, WEBP",
"formats": "Soporta JPG, PNG, GIF, WebP (máx 10MB)"
"media": {
"confirm_delete": "¿Eliminar \"{{name}}\"?",
"upload_failed": "Error al subir archivo",
"no_valid_files": "No se encontraron archivos válidos. Soporta: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Error al mover la nota.",
"failed_folder": "Error al mover la carpeta."
"failed_folder": "Error al mover la carpeta.",
"failed_media": "Error al mover el archivo multimedia."
},
"search": {

View File

@ -44,6 +44,7 @@
"no_notes": "Pas encore de notes. Créez votre première note !",
"no_notes_yet": "Pas encore de notes ni de dossiers.",
"create_first": "Créez votre première note ou dossier !",
"drop_to_root": "Déposer ici pour la racine",
"no_favorites": "Pas encore de favoris",
"no_results": "Aucun résultat trouvé",
"expand_all": "Développer tous les dossiers",
@ -194,16 +195,17 @@
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
},
"images": {
"confirm_delete": "Supprimer l'image \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement de l'image",
"no_valid_files": "Aucun fichier image valide trouvé. Formats supportés : JPG, PNG, GIF, WEBP",
"formats": "Supporte JPG, PNG, GIF, WebP (max 10 Mo)"
"media": {
"confirm_delete": "Supprimer \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement du fichier",
"no_valid_files": "Aucun fichier valide trouvé. Supporté : JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Échec du déplacement de la note.",
"failed_folder": "Échec du déplacement du dossier."
"failed_folder": "Échec du déplacement du dossier.",
"failed_media": "Échec du déplacement du fichier multimédia."
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Password errata. Riprova."
},
"images": {
"confirm_delete": "Eliminare l'immagine \"{{name}}\"?",
"upload_failed": "Caricamento immagine fallito",
"no_valid_files": "Nessun file immagine valido. Formati supportati: JPG, PNG, GIF, WEBP",
"formats": "Supporta JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "Eliminare \"{{name}}\"?",
"upload_failed": "Caricamento file fallito",
"no_valid_files": "Nessun file valido trovato. Supportati: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Impossibile spostare la nota.",
"failed_folder": "Impossibile spostare la cartella."
"failed_folder": "Impossibile spostare la cartella.",
"failed_media": "Impossibile spostare il file multimediale."
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。"
},
"images": {
"confirm_delete": "画像「{{name}}」を削除しますか?",
"upload_failed": "画像のアップロードに失敗しました",
"no_valid_files": "有効な画像ファイルが見つかりません。対応形式: JPG, PNG, GIF, WEBP",
"formats": "JPG, PNG, GIF, WebP対応最大10MB"
"media": {
"confirm_delete": "「{{name}}」を削除しますか?",
"upload_failed": "ファイルのアップロードに失敗しました",
"no_valid_files": "有効なファイルが見つかりません。対応: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "ノートの移動に失敗しました。",
"failed_folder": "フォルダの移動に失敗しました。"
"failed_folder": "フォルダの移動に失敗しました。",
"failed_media": "メディアファイルの移動に失敗しました。"
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Неверный пароль. Попробуйте снова."
},
"images": {
"confirm_delete": "Удалить изображение «{{name}}»?",
"upload_failed": "Не удалось загрузить изображение",
"no_valid_files": "Не найдено подходящих изображений. Поддерживаемые форматы: JPG, PNG, GIF, WEBP",
"formats": "Поддерживает JPG, PNG, GIF, WebP (макс. 10МБ)"
"media": {
"confirm_delete": "Удалить «{{name}}»?",
"upload_failed": "Не удалось загрузить файл",
"no_valid_files": "Не найдено подходящих файлов. Поддерживает: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Не удалось переместить заметку.",
"failed_folder": "Не удалось переместить папку."
"failed_folder": "Не удалось переместить папку.",
"failed_media": "Не удалось переместить медиафайл."
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova."
},
"images": {
"confirm_delete": "Izbrišem sliko \"{{name}}\"?",
"upload_failed": "Nalaganje slike ni uspelo",
"no_valid_files": "Ni najdenih veljavnih slikovnih datotek. Podprti formati: JPG, PNG, GIF, WEBP",
"formats": "Podpira JPG, PNG, GIF, WebP (največ 10MB)"
"media": {
"confirm_delete": "Izbrišem \"{{name}}\"?",
"upload_failed": "Nalaganje datoteke ni uspelo",
"no_valid_files": "Ni najdenih veljavnih datotek. Podprto: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "Napaka pri premikanju zapisa.",
"failed_folder": "Napaka pri premikanju mape."
"failed_folder": "Napaka pri premikanju mape.",
"failed_media": "Napaka pri premikanju medijske datoteke."
},
"search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "密码错误。请重试。"
},
"images": {
"confirm_delete": "删除图片 \"{{name}}\"",
"upload_failed": "上传图片失败",
"no_valid_files": "未找到有效的图片文件。支持的格式JPG、PNG、GIF、WEBP",
"formats": "支持 JPG、PNG、GIF、WebP最大 10MB"
"media": {
"confirm_delete": "删除 \"{{name}}\"",
"upload_failed": "上传文件失败",
"no_valid_files": "未找到有效文件。支持JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {
"failed_note": "移动笔记失败。",
"failed_folder": "移动文件夹失败。"
"failed_folder": "移动文件夹失败。",
"failed_media": "移动媒体文件失败。"
},
"search": {

3
run.py
View File

@ -73,7 +73,8 @@ def main():
"backend.main:app",
"--reload",
"--host", "0.0.0.0",
"--port", port
"--port", port,
"--timeout-graceful-shutdown", "2"
])
if __name__ == "__main__":