Skip to content

Commit c8647cc

Browse files
authored
Merge pull request #30 from gamosoft/features/add-images
- added image drag support - added image clipboard pasting support - removed need to hold CTRL while dragging
2 parents ff787b2 + 6d9f777 commit c8647cc

File tree

6 files changed

+645
-91
lines changed

6 files changed

+645
-91
lines changed

backend/main.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
move_folder,
3333
rename_folder,
3434
delete_folder,
35+
save_uploaded_image,
36+
validate_path_security,
3537
)
3638
from .plugins import PluginManager
3739
from .themes import get_available_themes, get_theme_css
@@ -433,6 +435,91 @@ async def create_new_folder(data: dict):
433435
raise HTTPException(status_code=500, detail=str(e))
434436

435437

438+
@api_router.get("/images/{image_path:path}")
439+
async def get_image(image_path: str):
440+
"""
441+
Serve an image file with authentication protection.
442+
"""
443+
try:
444+
notes_dir = config['storage']['notes_dir']
445+
full_path = Path(notes_dir) / image_path
446+
447+
# Security: Validate path is within notes directory
448+
if not validate_path_security(notes_dir, full_path):
449+
raise HTTPException(status_code=403, detail="Access denied")
450+
451+
# Check file exists and is an image
452+
if not full_path.exists() or not full_path.is_file():
453+
raise HTTPException(status_code=404, detail="Image not found")
454+
455+
# Validate it's an image file
456+
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
457+
if full_path.suffix.lower() not in allowed_extensions:
458+
raise HTTPException(status_code=400, detail="Not an image file")
459+
460+
# Return the file
461+
return FileResponse(full_path)
462+
except HTTPException:
463+
raise
464+
except Exception as e:
465+
raise HTTPException(status_code=500, detail=str(e))
466+
467+
468+
@api_router.post("/upload-image")
469+
async def upload_image(file: UploadFile = File(...), note_path: str = Form(...)):
470+
"""
471+
Upload an image file and save it to the attachments directory.
472+
Returns the relative path to the image for markdown linking.
473+
"""
474+
try:
475+
# Validate file type
476+
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
477+
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
478+
479+
# Get file extension
480+
file_ext = Path(file.filename).suffix.lower() if file.filename else ''
481+
482+
if file.content_type not in allowed_types and file_ext not in allowed_extensions:
483+
raise HTTPException(
484+
status_code=400,
485+
detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}"
486+
)
487+
488+
# Read file data
489+
file_data = await file.read()
490+
491+
# Validate file size (10MB max)
492+
max_size = 10 * 1024 * 1024 # 10MB in bytes
493+
if len(file_data) > max_size:
494+
raise HTTPException(
495+
status_code=400,
496+
detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
497+
)
498+
499+
# Save the image
500+
image_path = save_uploaded_image(
501+
config['storage']['notes_dir'],
502+
note_path,
503+
file.filename,
504+
file_data
505+
)
506+
507+
if not image_path:
508+
raise HTTPException(status_code=500, detail="Failed to save image")
509+
510+
return {
511+
"success": True,
512+
"path": image_path,
513+
"filename": Path(image_path).name,
514+
"message": "Image uploaded successfully"
515+
}
516+
517+
except HTTPException:
518+
raise
519+
except Exception as e:
520+
raise HTTPException(status_code=500, detail=str(e))
521+
522+
436523
@api_router.post("/notes/move")
437524
async def move_note_endpoint(data: dict):
438525
"""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/API.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,67 @@ Content-Type: application/json
8181
}
8282
```
8383

84+
## 🖼️ Images
85+
86+
### Get Image
87+
```http
88+
GET /api/images/{image_path}
89+
```
90+
Retrieve an image file with authentication protection.
91+
92+
**Example:**
93+
```bash
94+
curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png
95+
```
96+
97+
**Security Note:** This endpoint requires authentication and validates that:
98+
- The image path is within the notes directory (prevents directory traversal)
99+
- The file exists and is a valid image format
100+
- The requesting user is authenticated (if auth is enabled)
101+
102+
### Upload Image
103+
```http
104+
POST /api/upload-image
105+
Content-Type: multipart/form-data
106+
107+
file: <image file>
108+
note_path: <path of note to attach to>
109+
```
110+
111+
Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts.
112+
113+
**Supported formats:** JPG, JPEG, PNG, GIF, WEBP
114+
**Maximum size:** 10MB
115+
116+
**Response:**
117+
```json
118+
{
119+
"success": true,
120+
"path": "folder/_attachments/image-20240417093343.png",
121+
"filename": "image-20240417093343.png",
122+
"message": "Image uploaded successfully"
123+
}
124+
```
125+
126+
**Example (using curl):**
127+
```bash
128+
curl -X POST http://localhost:8000/api/upload-image \
129+
-F "file=@/path/to/image.png" \
130+
-F "note_path=folder/mynote.md"
131+
```
132+
133+
**Windows PowerShell:**
134+
```powershell
135+
curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md"
136+
```
137+
138+
**Notes:**
139+
- Images are stored in `_attachments` folders relative to the note's location
140+
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`)
141+
- Images appear in the sidebar navigation and can be viewed/deleted directly
142+
- Drag & drop images into the editor automatically uploads and inserts markdown
143+
- All image access requires authentication when security is enabled
144+
84145
## 📁 Folders
85146

86147
### Create Folder
@@ -93,6 +154,17 @@ Content-Type: application/json
93154
}
94155
```
95156

157+
### Delete Folder
158+
```http
159+
DELETE /api/folders/{folder_path}
160+
```
161+
Deletes a folder and all its contents.
162+
163+
**Example:**
164+
```bash
165+
curl -X DELETE http://localhost:8000/api/folders/Projects/Archive
166+
```
167+
96168
### Move Folder
97169
```http
98170
POST /api/folders/move

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)