From 15ff45ddf91b1ffe8f25eb681f5c699b0388e706 Mon Sep 17 00:00:00 2001 From: Guillermo Villar Date: Thu, 20 Nov 2025 14:10:17 +0100 Subject: [PATCH 1/3] added image upload support! --- backend/main.py | 60 +++++++ backend/utils.py | 140 +++++++++++++++- documentation/FEATURES.md | 8 +- frontend/app.js | 342 +++++++++++++++++++++++++++++++++----- frontend/index.html | 69 +++++--- 5 files changed, 545 insertions(+), 74 deletions(-) diff --git a/backend/main.py b/backend/main.py index f848479..544317a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,6 +32,7 @@ move_folder, rename_folder, delete_folder, + save_uploaded_image, ) from .plugins import PluginManager from .themes import get_available_themes, get_theme_css @@ -79,6 +80,10 @@ static_path = Path(__file__).parent.parent / "frontend" app.mount("/static", StaticFiles(directory=static_path), name="static") +# Mount data directory for serving images +data_path = Path(config['storage']['notes_dir']) +app.mount("/data", StaticFiles(directory=data_path), name="data") + # ============================================================================ # Custom Exception Handlers @@ -433,6 +438,61 @@ async def create_new_folder(data: dict): raise HTTPException(status_code=500, detail=str(e)) +@api_router.post("/upload-image") +async def upload_image(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. + """ + try: + # Validate file type + allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'} + allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + + # 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: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. 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 + 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" + ) + + # Save the image + image_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") + + return { + "success": True, + "path": image_path, + "filename": Path(image_path).name, + "message": "Image uploaded successfully" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @api_router.post("/notes/move") async def move_note_endpoint(data: dict): """Move a note to a different folder""" diff --git a/backend/utils.py b/backend/utils.py index 9020cbd..e953e93 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool: def get_all_notes(notes_dir: str) -> List[Dict]: - """Recursively get all markdown notes""" - notes = [] + """Recursively get all markdown notes and images""" + items = [] notes_path = Path(notes_dir) + # Get all markdown notes for md_file in notes_path.rglob("*.md"): relative_path = md_file.relative_to(notes_path) stat = md_file.stat() - notes.append({ + items.append({ "name": md_file.stem, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), - "size": stat.st_size + "size": stat.st_size, + "type": "note" }) - return sorted(notes, key=lambda x: x['modified'], reverse=True) + # Get all images + images = get_all_images(notes_dir) + items.extend(images) + + return sorted(items, key=lambda x: x['modified'], reverse=True) 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: "lines": line_count } + +def sanitize_filename(filename: str) -> str: + """ + Sanitize a filename by removing/replacing invalid characters. + Keeps only alphanumeric chars, dots, dashes, and underscores. + """ + # Get the extension first + parts = filename.rsplit('.', 1) + name = parts[0] + ext = parts[1] if len(parts) > 1 else '' + + # Remove/replace invalid characters + name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + + # Rejoin with extension + return f"{name}.{ext}" if ext else name + + +def get_attachment_dir(notes_dir: str, note_path: str) -> Path: + """ + Get the attachments directory for a given note. + If note is in root, returns /data/_attachments/ + If note is in folder, returns /data/folder/_attachments/ + """ + if not note_path: + # Root level + return Path(notes_dir) / "_attachments" + + note_path_obj = Path(note_path) + folder = note_path_obj.parent + + if str(folder) == '.': + # Note is in root + return Path(notes_dir) / "_attachments" + else: + # Note is in a folder + return Path(notes_dir) / folder / "_attachments" + + +def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]: + """ + Save an uploaded image to the appropriate attachments directory. + Returns the relative path to the image if successful, None otherwise. + + Args: + notes_dir: Base notes directory + note_path: Path of the note the image is being uploaded to + filename: Original filename + file_data: Binary file data + + Returns: + Relative path to the saved image, or None if failed + """ + # Sanitize filename + sanitized_name = sanitize_filename(filename) + + # Get extension + ext = Path(sanitized_name).suffix + name_without_ext = Path(sanitized_name).stem + + # Add timestamp to prevent collisions + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + final_filename = f"{name_without_ext}-{timestamp}{ext}" + + # Get attachments directory + attachments_dir = get_attachment_dir(notes_dir, note_path) + + # Create directory if it doesn't exist + attachments_dir.mkdir(parents=True, exist_ok=True) + + # Full path to save the image + full_path = attachments_dir / final_filename + + # Security check + if not validate_path_security(notes_dir, full_path): + print(f"Security: Attempted to save image outside notes directory: {full_path}") + return None + + try: + # Write the file + with open(full_path, 'wb') as f: + f.write(file_data) + + # Return relative path from notes_dir + relative_path = full_path.relative_to(Path(notes_dir)) + return str(relative_path.as_posix()) + except Exception as e: + print(f"Error saving image: {e}") + return None + + +def get_all_images(notes_dir: str) -> List[Dict]: + """ + Get all images from attachments directories. + Returns list of image dictionaries with metadata. + """ + images = [] + 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).isoformat(), + "size": stat.st_size, + "type": "image" + }) + + return images + diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index d0a6022..994fe06 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -13,6 +13,11 @@ - **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md)) - **HTML Export** - Export notes as standalone HTML files +### Image Support +- **Drag & drop upload** - Drop images 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) + ### Organization - **Folder hierarchy** - Organize notes in nested folders - **Drag & drop** - Move notes and folders effortlessly @@ -24,7 +29,7 @@ ### Internal Links - **Wiki-style links** - `[[Note Name]]` syntax -- **Drag to link** - Hold Ctrl and drag a note into the editor +- **Drag to link** - Drag notes or images into the editor to insert links - **Click to navigate** - Jump between notes seamlessly - **External links** - Open in new tabs automatically @@ -143,7 +148,6 @@ graph TD | `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | | `F3` | `F3` | Next search match | | `Shift+F3` | `Shift+F3` | Previous search match | -| `Ctrl+Drag` | `Cmd+Drag` | Create internal link | ## 🚀 Performance diff --git a/frontend/app.js b/frontend/app.js index 849b33f..c0a7eaf 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -64,8 +64,9 @@ function noteApp() { // Scroll sync state isScrolling: false, - // Drag state for internal linking - draggedNoteForLink: null, + // Unified drag state + draggedItem: null, // { path: string, type: 'note' | 'image' } + dropTarget: null, // 'editor' | 'folder' | null // Undo/Redo history undoHistory: [], @@ -95,6 +96,9 @@ function noteApp() { // Mermaid state cache lastMermaidTheme: null, + // Image viewer state + currentImage: '', + // DOM element cache (to avoid repeated querySelector calls) _domCache: { editor: null, @@ -131,6 +135,8 @@ function noteApp() { this.searchResults = []; this.clearSearchHighlights(); } + } else if (e.state && e.state.imagePath) { + this.viewImage(e.state.imagePath, false); // false = don't update history } }); @@ -525,28 +531,46 @@ function noteApp() { }); } - // Then, render notes in this folder (after subfolders) + // Then, render notes and images in this folder (after subfolders) if (folder.notes && folder.notes.length > 0) { folder.notes.forEach(note => { + // Check if it's an image or a note + const isImage = note.type === 'image'; const isCurrentNote = this.currentNote === note.path; + const isCurrentImage = this.currentImage === note.path; + const isCurrent = isImage ? isCurrentImage : isCurrentNote; + + // Different icon for images + const icon = isImage ? '🖼️' : ''; + + // Click handler + const clickHandler = isImage + ? `viewImage('${note.path.replace(/'/g, "\\'")}')` + : `loadNote('${note.path.replace(/'/g, "\\'")}')`; + + // Delete handler + const deleteHandler = isImage + ? `deleteImage('${note.path.replace(/'/g, "\\'")}')` + : `deleteNote('${note.path.replace(/'/g, "\\'")}', '${note.name.replace(/'/g, "\\'")}')`; + html += `
- ${note.name} + ${icon}${icon ? ' ' : ''}${note.name}
@@ -939,24 +947,27 @@

- +