From ef82c277c01da46af69ad1a590482087c88301f2 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 16:00:58 +0200 Subject: [PATCH] changed target folder, fixed bugs --- backend/main.py | 37 ++++++++++++++++++++-- backend/utils.py | 81 ++++++++++++++++++++++++++++-------------------- frontend/app.js | 78 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/backend/main.py b/backend/main.py index bbb0b2a..cff914b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -677,9 +677,16 @@ async def put_media(media_path: str, request: Request): @api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form("")): +async def upload_media( + request: Request, + file: UploadFile = File(...), + note_path: str = Form(""), + content_folder: str = Form(""), + next_to_notes: str = Form(""), +): """ - Upload a media file (image, audio, video, PDF) and save it to the attachments directory. + Upload a media file (image, audio, video, PDF) and save it to the attachments directory, + or (when next_to_notes=1) save a new drawing PNG next to markdown notes in content_folder. Returns the relative path for markdown linking. """ try: @@ -727,6 +734,32 @@ async def upload_media(request: Request, file: UploadFile = File(...), note_path 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" ) + if (next_to_notes or "").strip() == "1": + is_png = file.content_type in ("image/png",) or (file.filename and file.filename.lower().endswith(".png")) + if not is_png: + raise HTTPException( + status_code=400, + detail="next_to_notes requires a PNG file", + ) + file_path = save_uploaded_image( + config["storage"]["notes_dir"], + "", + file.filename or "drawing.png", + file_data, + sibling_folder=content_folder or "", + ) + if not file_path: + raise HTTPException(status_code=500, detail="Failed to save drawing") + out_name = Path(file_path).name + media_type = get_media_type(out_name) or "drawing" + return { + "success": True, + "path": file_path, + "filename": out_name, + "type": media_type, + "message": "Drawing created", + } + # Save the file (reusing image save function - it works for any file) file_path = save_uploaded_image( config['storage']['notes_dir'], diff --git a/backend/utils.py b/backend/utils.py index e639c93..06a37ac 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -604,54 +604,67 @@ def get_attachment_dir(notes_dir: str, note_path: str) -> Path: return Path(notes_dir) / folder / "_attachments" -def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data: bytes) -> Optional[str]: +def save_uploaded_image( + notes_dir: str, + note_path: str, + filename: str, + file_data: bytes, + *, + sibling_folder: Optional[str] = None, +) -> 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 + Save uploaded media under the vault. + + Default (sibling_folder is None): store in ``_attachments`` next to the note implied by + ``note_path`` (drag/drop, paste, etc.). + + If ``sibling_folder`` is set (including ``""`` for vault root): store ``drawing-{timestamp}.png`` + in that folder next to ``.md`` files — used for new drawings from the + menu. + + Returns a relative path from ``notes_dir``, or None on failure. """ - # Sanitize filename + base = Path(notes_dir) + + if sibling_folder is not None: + cf = (sibling_folder or "").strip().replace("\\", "/") + segments = [p for p in cf.split("/") if p and p != "."] + if any(p == ".." for p in segments): + return None + dest_dir = base.joinpath(*segments) if segments else base + if not validate_path_security(notes_dir, dest_dir / ".nd_probe"): + return None + try: + dest_dir.mkdir(parents=True, exist_ok=True) + except OSError: + return None + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + full_path = dest_dir / f"drawing-{timestamp}.png" + if not validate_path_security(notes_dir, full_path): + return None + try: + with open(full_path, "wb") as f: + f.write(file_data) + return str(full_path.relative_to(base).as_posix()) + except OSError as e: + print(f"Error saving image: {e}") + return None + 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') + 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: + 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: + return str(full_path.relative_to(base).as_posix()) + except OSError as e: print(f"Error saving image: {e}") return None diff --git a/frontend/app.js b/frontend/app.js index b27d0b1..d818efb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,7 +2,7 @@ // Configuration constants const CONFIG = { - AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave + AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave) SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference @@ -504,6 +504,7 @@ function noteApp() { drawingRedoStack: [], drawingDraft: null, drawingIsPointerDown: false, + _drawingAutosaveTimeout: null, // DOM element cache (to avoid repeated querySelector calls) _domCache: { @@ -2579,10 +2580,18 @@ function noteApp() { }, // Upload a media file (image, audio, video, PDF) - async uploadMedia(file, notePath) { + // Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments). + async uploadMedia(file, notePath, options = {}) { + const { nextToNotes, contentFolder } = options; const formData = new FormData(); formData.append('file', file); - formData.append('note_path', notePath || ''); + if (nextToNotes) { + formData.append('next_to_notes', '1'); + formData.append('content_folder', contentFolder != null ? contentFolder : ''); + formData.append('note_path', ''); + } else { + formData.append('note_path', notePath || ''); + } try { const response = await fetch('/api/upload-media', { @@ -2715,6 +2724,7 @@ function noteApp() { viewMedia(mediaPath, mediaType = null, updateHistory = true) { if (this.currentMediaType === 'drawing') { this._drawingDisconnectResizeObserver(); + this._drawingCancelAutosave(); } this.showGraph = false; // Ensure graph is closed this.currentNote = ''; @@ -2811,12 +2821,14 @@ function noteApp() { } const file = new File([blob], 'drawing.png', { type: 'image/png' }); try { - const notePath = targetFolder ? `${targetFolder}/_.md` : ''; - const path = await this.uploadMedia(file, notePath); + const path = await this.uploadMedia(file, '', { + nextToNotes: true, + contentFolder: targetFolder, + }); await this.loadNotes(); this.viewMedia(path, 'drawing'); } catch (error) { - ErrorHandler.handle('upload drawing', error); + ErrorHandler.handle('create drawing', error); } }, @@ -2924,6 +2936,40 @@ function noteApp() { } }, + _drawingCancelAutosave() { + if (this._drawingAutosaveTimeout) { + clearTimeout(this._drawingAutosaveTimeout); + this._drawingAutosaveTimeout = null; + } + }, + + /** + * Debounced PNG save (same delay as notes). Never runs while the primary button is held + * (active stroke/shape); pending timers are cleared when a new stroke starts. + */ + _drawingScheduleAutosave() { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + if (this._drawingAutosaveTimeout) { + clearTimeout(this._drawingAutosaveTimeout); + this._drawingAutosaveTimeout = null; + } + const path = this.currentMedia; + const pollMs = 150; + const attemptSave = () => { + if (this.currentMedia !== path || this.currentMediaType !== 'drawing') { + this._drawingAutosaveTimeout = null; + return; + } + if (this.drawingIsPointerDown) { + this._drawingAutosaveTimeout = setTimeout(attemptSave, pollMs); + return; + } + this._drawingAutosaveTimeout = null; + this.drawingSave({ silent: true }); + }; + this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY); + }, + /** Size canvas to drawingCanvasWrap and redraw (fills available pane). */ _drawingLayoutCanvas() { const wrap = this.$refs.drawingCanvasWrap; @@ -3016,6 +3062,7 @@ function noteApp() { if (this.currentMediaType !== 'drawing' || e.button !== 0) return; const canvas = this._drawingCanvasEl; if (!canvas) return; + this._drawingCancelAutosave(); canvas.setPointerCapture(e.pointerId); this._drawingPointerId = e.pointerId; const { x, y } = this._drawingCanvasCoords(e); @@ -3122,22 +3169,27 @@ function noteApp() { }); } this.drawingRedraw(); + this._drawingScheduleAutosave(); }, drawingUndo() { if (this.drawingOps.length === 0) return; this.drawingRedoStack.push(this.drawingOps.pop()); this.drawingRedraw(); + this._drawingScheduleAutosave(); }, drawingRedo() { if (this.drawingRedoStack.length === 0) return; this.drawingOps.push(this.drawingRedoStack.pop()); this.drawingRedraw(); + this._drawingScheduleAutosave(); }, - async drawingSave() { + async drawingSave(options = {}) { + const { silent = false } = options; if (!this.currentMedia || this.currentMediaType !== 'drawing') return; + this._drawingCancelAutosave(); const canvas = this._drawingCanvasEl; const ctx = this._drawingCtx; if (!canvas || !ctx) return; @@ -3148,6 +3200,7 @@ function noteApp() { const blob = await new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); }); + this.isSaving = true; try { const enc = this._drawingEncodeMediaPath(); const res = await fetch(`/api/media/${enc}`, { @@ -3172,9 +3225,18 @@ function noteApp() { this.drawingOps = []; this.drawingRedoStack = []; await this.initDrawingViewer(); - this.toast(this.t('drawing.saved'), { type: 'success' }); + if (!silent) { + this.toast(this.t('drawing.saved'), { type: 'success' }); + } else { + this.lastSaved = true; + setTimeout(() => { + this.lastSaved = false; + }, CONFIG.SAVE_INDICATOR_DURATION); + } } catch (error) { ErrorHandler.handle('save drawing', error); + } finally { + this.isSaving = false; } },