Skip to content

Commit 15ff45d

Browse files
author
Guillermo Villar
committed
added image upload support!
1 parent ff787b2 commit 15ff45d

File tree

5 files changed

+545
-74
lines changed

5 files changed

+545
-74
lines changed

backend/main.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
move_folder,
3333
rename_folder,
3434
delete_folder,
35+
save_uploaded_image,
3536
)
3637
from .plugins import PluginManager
3738
from .themes import get_available_themes, get_theme_css
@@ -79,6 +80,10 @@
7980
static_path = Path(__file__).parent.parent / "frontend"
8081
app.mount("/static", StaticFiles(directory=static_path), name="static")
8182

83+
# Mount data directory for serving images
84+
data_path = Path(config['storage']['notes_dir'])
85+
app.mount("/data", StaticFiles(directory=data_path), name="data")
86+
8287

8388
# ============================================================================
8489
# Custom Exception Handlers
@@ -433,6 +438,61 @@ async def create_new_folder(data: dict):
433438
raise HTTPException(status_code=500, detail=str(e))
434439

435440

441+
@api_router.post("/upload-image")
442+
async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)):
443+
"""
444+
Upload an image file and save it to the attachments directory.
445+
Returns the relative path to the image for markdown linking.
446+
"""
447+
try:
448+
# Validate file type
449+
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
450+
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
451+
452+
# Get file extension
453+
file_ext = Path(file.filename).suffix.lower() if file.filename else ''
454+
455+
if file.content_type not in allowed_types and file_ext not in allowed_extensions:
456+
raise HTTPException(
457+
status_code=400,
458+
detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}"
459+
)
460+
461+
# Read file data
462+
file_data = await file.read()
463+
464+
# Validate file size (10MB max)
465+
max_size = 10 * 1024 * 1024 # 10MB in bytes
466+
if len(file_data) > max_size:
467+
raise HTTPException(
468+
status_code=400,
469+
detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
470+
)
471+
472+
# Save the image
473+
image_path = save_uploaded_image(
474+
config['storage']['notes_dir'],
475+
note_path,
476+
file.filename,
477+
file_data
478+
)
479+
480+
if not image_path:
481+
raise HTTPException(status_code=500, detail="Failed to save image")
482+
483+
return {
484+
"success": True,
485+
"path": image_path,
486+
"filename": Path(image_path).name,
487+
"message": "Image uploaded successfully"
488+
}
489+
490+
except HTTPException:
491+
raise
492+
except Exception as e:
493+
raise HTTPException(status_code=500, detail=str(e))
494+
495+
436496
@api_router.post("/notes/move")
437497
async def move_note_endpoint(data: dict):
438498
"""Move a note to a different folder"""

backend/utils.py

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:
157157

158158

159159
def get_all_notes(notes_dir: str) -> List[Dict]:
160-
"""Recursively get all markdown notes"""
161-
notes = []
160+
"""Recursively get all markdown notes and images"""
161+
items = []
162162
notes_path = Path(notes_dir)
163163

164+
# Get all markdown notes
164165
for md_file in notes_path.rglob("*.md"):
165166
relative_path = md_file.relative_to(notes_path)
166167
stat = md_file.stat()
167168

168-
notes.append({
169+
items.append({
169170
"name": md_file.stem,
170171
"path": str(relative_path.as_posix()),
171172
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
172173
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
173-
"size": stat.st_size
174+
"size": stat.st_size,
175+
"type": "note"
174176
})
175177

176-
return sorted(notes, key=lambda x: x['modified'], reverse=True)
178+
# Get all images
179+
images = get_all_images(notes_dir)
180+
items.extend(images)
181+
182+
return sorted(items, key=lambda x: x['modified'], reverse=True)
177183

178184

179185
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
@@ -296,3 +302,127 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
296302
"lines": line_count
297303
}
298304

305+
306+
def sanitize_filename(filename: str) -> str:
307+
"""
308+
Sanitize a filename by removing/replacing invalid characters.
309+
Keeps only alphanumeric chars, dots, dashes, and underscores.
310+
"""
311+
# Get the extension first
312+
parts = filename.rsplit('.', 1)
313+
name = parts[0]
314+
ext = parts[1] if len(parts) > 1 else ''
315+
316+
# Remove/replace invalid characters
317+
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
318+
319+
# Rejoin with extension
320+
return f"{name}.{ext}" if ext else name
321+
322+
323+
def get_attachment_dir(notes_dir: str, note_path: str) -> Path:
324+
"""
325+
Get the attachments directory for a given note.
326+
If note is in root, returns /data/_attachments/
327+
If note is in folder, returns /data/folder/_attachments/
328+
"""
329+
if not note_path:
330+
# Root level
331+
return Path(notes_dir) / "_attachments"
332+
333+
note_path_obj = Path(note_path)
334+
folder = note_path_obj.parent
335+
336+
if str(folder) == '.':
337+
# Note is in root
338+
return Path(notes_dir) / "_attachments"
339+
else:
340+
# Note is in a folder
341+
return Path(notes_dir) / folder / "_attachments"
342+
343+
344+
def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]:
345+
"""
346+
Save an uploaded image to the appropriate attachments directory.
347+
Returns the relative path to the image if successful, None otherwise.
348+
349+
Args:
350+
notes_dir: Base notes directory
351+
note_path: Path of the note the image is being uploaded to
352+
filename: Original filename
353+
file_data: Binary file data
354+
355+
Returns:
356+
Relative path to the saved image, or None if failed
357+
"""
358+
# Sanitize filename
359+
sanitized_name = sanitize_filename(filename)
360+
361+
# Get extension
362+
ext = Path(sanitized_name).suffix
363+
name_without_ext = Path(sanitized_name).stem
364+
365+
# Add timestamp to prevent collisions
366+
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
367+
final_filename = f"{name_without_ext}-{timestamp}{ext}"
368+
369+
# Get attachments directory
370+
attachments_dir = get_attachment_dir(notes_dir, note_path)
371+
372+
# Create directory if it doesn't exist
373+
attachments_dir.mkdir(parents=True, exist_ok=True)
374+
375+
# Full path to save the image
376+
full_path = attachments_dir / final_filename
377+
378+
# Security check
379+
if not validate_path_security(notes_dir, full_path):
380+
print(f"Security: Attempted to save image outside notes directory: {full_path}")
381+
return None
382+
383+
try:
384+
# Write the file
385+
with open(full_path, 'wb') as f:
386+
f.write(file_data)
387+
388+
# Return relative path from notes_dir
389+
relative_path = full_path.relative_to(Path(notes_dir))
390+
return str(relative_path.as_posix())
391+
except Exception as e:
392+
print(f"Error saving image: {e}")
393+
return None
394+
395+
396+
def get_all_images(notes_dir: str) -> List[Dict]:
397+
"""
398+
Get all images from attachments directories.
399+
Returns list of image dictionaries with metadata.
400+
"""
401+
images = []
402+
notes_path = Path(notes_dir)
403+
404+
# Common image extensions
405+
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
406+
407+
# Find all attachments directories
408+
for attachments_dir in notes_path.rglob("_attachments"):
409+
if not attachments_dir.is_dir():
410+
continue
411+
412+
# Find all images in this attachments directory
413+
for image_file in attachments_dir.iterdir():
414+
if image_file.is_file() and image_file.suffix.lower() in image_extensions:
415+
relative_path = image_file.relative_to(notes_path)
416+
stat = image_file.stat()
417+
418+
images.append({
419+
"name": image_file.name,
420+
"path": str(relative_path.as_posix()),
421+
"folder": str(relative_path.parent.as_posix()),
422+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
423+
"size": stat.st_size,
424+
"type": "image"
425+
})
426+
427+
return images
428+

documentation/FEATURES.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
1414
- **HTML Export** - Export notes as standalone HTML files
1515

16+
### Image Support
17+
- **Drag & drop upload** - Drop images from your file system directly into the editor
18+
- **Clipboard paste** - Paste images from clipboard with Ctrl+V
19+
- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB)
20+
1621
### Organization
1722
- **Folder hierarchy** - Organize notes in nested folders
1823
- **Drag & drop** - Move notes and folders effortlessly
@@ -24,7 +29,7 @@
2429

2530
### Internal Links
2631
- **Wiki-style links** - `[[Note Name]]` syntax
27-
- **Drag to link** - Hold Ctrl and drag a note into the editor
32+
- **Drag to link** - Drag notes or images into the editor to insert links
2833
- **Click to navigate** - Jump between notes seamlessly
2934
- **External links** - Open in new tabs automatically
3035

@@ -143,7 +148,6 @@ graph TD
143148
| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo |
144149
| `F3` | `F3` | Next search match |
145150
| `Shift+F3` | `Shift+F3` | Previous search match |
146-
| `Ctrl+Drag` | `Cmd+Drag` | Create internal link |
147151

148152
## 🚀 Performance
149153

0 commit comments

Comments
 (0)