diff --git a/backend/main.py b/backend/main.py index de8ecdd..de63a08 100644 --- a/backend/main.py +++ b/backend/main.py @@ -641,10 +641,10 @@ async def move_note_endpoint(request: Request, data: dict): if not old_path or not new_path: raise HTTPException(status_code=400, detail="Both oldPath and newPath required") - success = move_note(config['storage']['notes_dir'], old_path, new_path) + success, error_msg = move_note(config['storage']['notes_dir'], old_path, new_path) if not success: - raise HTTPException(status_code=500, detail="Failed to move note") + raise HTTPException(status_code=400, detail=error_msg or "Failed to move note") # Run plugin hooks plugin_manager.run_hook('on_note_save', note_path=new_path, content='') @@ -672,10 +672,10 @@ async def move_folder_endpoint(request: Request, data: dict): if not old_path or not new_path: raise HTTPException(status_code=400, detail="Both oldPath and newPath required") - success = move_folder(config['storage']['notes_dir'], old_path, new_path) + success, error_msg = move_folder(config['storage']['notes_dir'], old_path, new_path) if not success: - raise HTTPException(status_code=500, detail="Failed to move folder") + raise HTTPException(status_code=400, detail=error_msg or "Failed to move folder") return { "success": True, @@ -700,10 +700,10 @@ async def rename_folder_endpoint(request: Request, data: dict): if not old_path or not new_path: raise HTTPException(status_code=400, detail="Both oldPath and newPath required") - success = rename_folder(config['storage']['notes_dir'], old_path, new_path) + success, error_msg = rename_folder(config['storage']['notes_dir'], old_path, new_path) if not success: - raise HTTPException(status_code=500, detail="Failed to rename folder") + raise HTTPException(status_code=400, detail=error_msg or "Failed to rename folder") return { "success": True, diff --git a/backend/utils.py b/backend/utils.py index ef71ca4..34f46a0 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -73,57 +73,70 @@ def get_all_folders(notes_dir: str) -> List[str]: return sorted(folders) -def move_note(notes_dir: str, old_path: str, new_path: str) -> bool: - """Move a note to a different location""" +def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]: + """Move a note to a different location + + Returns: + Tuple of (success: bool, error_message: str) + """ old_full_path = Path(notes_dir) / old_path new_full_path = Path(notes_dir) / new_path # Security checks - if not validate_path_security(notes_dir, old_full_path) or \ - not validate_path_security(notes_dir, new_full_path): - return False + if not validate_path_security(notes_dir, old_full_path): + return False, "Invalid source path" + if not validate_path_security(notes_dir, new_full_path): + return False, "Invalid destination path" if not old_full_path.exists(): - return False + return False, f"Source note does not exist: {old_path}" # Check if target already exists (prevent overwriting) if new_full_path.exists(): - return False + return False, f"A note already exists at: {new_path}" # Invalidate cache for old path old_key = str(old_full_path) if old_key in _tag_cache: del _tag_cache[old_key] - # Create parent directory if needed - new_full_path.parent.mkdir(parents=True, exist_ok=True) - - # Move the file - old_full_path.rename(new_full_path) + try: + # Create parent directory if needed + new_full_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the file + old_full_path.rename(new_full_path) + except Exception as e: + return False, f"Failed to move file: {str(e)}" # Note: We don't automatically delete empty folders to preserve user's folder structure - return True + return True, "" -def move_folder(notes_dir: str, old_path: str, new_path: str) -> bool: - """Move a folder to a different location""" +def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]: + """Move a folder to a different location + + Returns: + Tuple of (success: bool, error_message: str) + """ import shutil old_full_path = Path(notes_dir) / old_path new_full_path = Path(notes_dir) / new_path # Security checks - if not validate_path_security(notes_dir, old_full_path) or \ - not validate_path_security(notes_dir, new_full_path): - return False + if not validate_path_security(notes_dir, old_full_path): + return False, "Invalid source path" + if not validate_path_security(notes_dir, new_full_path): + return False, "Invalid destination path" if not old_full_path.exists() or not old_full_path.is_dir(): - return False + return False, f"Source folder does not exist: {old_path}" # Check if target already exists if new_full_path.exists(): - return False + return False, f"A folder already exists at: {new_path}" # Invalidate cache for all notes in this folder global _tag_cache @@ -132,18 +145,21 @@ def move_folder(notes_dir: str, old_path: str, new_path: str) -> bool: for key in keys_to_delete: del _tag_cache[key] - # Create parent directory if needed - new_full_path.parent.mkdir(parents=True, exist_ok=True) - - # Move the folder - shutil.move(str(old_full_path), str(new_full_path)) + try: + # Create parent directory if needed + new_full_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the folder + shutil.move(str(old_full_path), str(new_full_path)) + except Exception as e: + return False, f"Failed to move folder: {str(e)}" # Note: We don't automatically delete empty folders to preserve user's folder structure - return True + return True, "" -def rename_folder(notes_dir: str, old_path: str, new_path: str) -> bool: +def rename_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]: """Rename a folder (same as move but for clarity)""" return move_folder(notes_dir, old_path, new_path) diff --git a/frontend/app.js b/frontend/app.js index d9ced52..329f8d6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1066,14 +1066,13 @@ function noteApp() { if (this.favoritesSet.has(path)) { // Remove from favorites - this.favoritesSet.delete(path); this.favorites = this.favorites.filter(f => f !== path); } else { // Add to favorites - this.favoritesSet.add(path); - this.favorites.push(path); + this.favorites = [...this.favorites, path]; } - + // Recreate Set from array for consistency + this.favoritesSet = new Set(this.favorites); this.saveFavorites(); }, @@ -1081,10 +1080,14 @@ function noteApp() { get favoriteNotes() { return this.favorites .map(path => { - const note = this.notes.find(n => n.path === path); + // Find note by exact path or case-insensitive match + let note = this.notes.find(n => n.path === path); + if (!note) { + note = this.notes.find(n => n.path.toLowerCase() === path.toLowerCase()); + } if (!note) return null; return { - path: note.path, + path: note.path, // Use actual path from notes (fixes case issues) name: note.path.split('/').pop().replace('.md', ''), folder: note.folder || '' }; @@ -1936,15 +1939,18 @@ function noteApp() { return; } + // IMPORTANT: Capture dragged item info immediately before async operations + // because ondragend may fire and clear these values + const draggedNotePath = this.draggedNote || (this.draggedItem?.type === 'note' ? this.draggedItem.path : null); + const draggedFolderPath = this.draggedFolder; + // Handle note drop into folder - if (this.draggedNote) { - const note = this.notes.find(n => n.path === this.draggedNote); + if (draggedNotePath) { + const note = this.notes.find(n => n.path === draggedNotePath); if (!note) return; // Don't allow moving images to folders if (note.type === 'image') { - this.draggedNote = null; - this.draggedItem = null; return; } @@ -1952,82 +1958,112 @@ function noteApp() { const filename = note.path.split('/').pop(); const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; - if (newPath === this.draggedNote) { - this.draggedNote = null; + if (newPath === draggedNotePath) { return; } + // Check if this note is favorited BEFORE the async call + const wasFavorited = this.favoritesSet.has(draggedNotePath); + try { const response = await fetch('/api/notes/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - oldPath: this.draggedNote, + oldPath: draggedNotePath, newPath: newPath }) }); if (response.ok) { - await this.loadNotes(); + // Update favorites if the moved note was favorited + if (wasFavorited) { + // Update Array (create new array for reactivity) + const newFavorites = this.favorites.map(f => f === draggedNotePath ? newPath : f); + this.favorites = newFavorites; + // Recreate Set from array for consistency + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + // Keep current note open if it was the moved note - if (this.currentNote === this.draggedNote) { + const wasCurrentNote = this.currentNote === draggedNotePath; + + await this.loadNotes(); + + if (wasCurrentNote) { this.currentNote = newPath; } } else { - alert('Failed to move note.'); + const errorData = await response.json().catch(() => ({})); + alert(errorData.detail || 'Failed to move note.'); } } catch (error) { console.error('Failed to move note:', error); alert('Failed to move note.'); } - this.draggedNote = null; return; } // Handle folder drop into folder - if (this.draggedFolder) { + if (draggedFolderPath) { // Prevent dropping folder into itself or its subfolders - if (targetFolderPath === this.draggedFolder || - targetFolderPath.startsWith(this.draggedFolder + '/')) { + if (targetFolderPath === draggedFolderPath || + targetFolderPath.startsWith(draggedFolderPath + '/')) { alert('Cannot move folder into itself or its subfolder.'); - this.draggedFolder = null; return; } - const folderName = this.draggedFolder.split('/').pop(); + const folderName = draggedFolderPath.split('/').pop(); const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName; - if (newPath === this.draggedFolder) { - this.draggedFolder = null; + if (newPath === draggedFolderPath) { return; } + // Capture favorites info before async call + const oldPrefix = draggedFolderPath + '/'; + const newPrefix = newPath + '/'; + try { const response = await fetch('/api/folders/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - oldPath: this.draggedFolder, + oldPath: draggedFolderPath, newPath: newPath }) }); if (response.ok) { + // Update favorites that were in the moved folder + const newFavorites = this.favorites.map(f => { + if (f.startsWith(oldPrefix)) { + return f.replace(oldPrefix, newPrefix); + } + return f; + }); + // Check if anything changed + if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) { + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + await this.loadNotes(); // Update current note path if it was in the moved folder - if (this.currentNote && this.currentNote.startsWith(this.draggedFolder + '/')) { - this.currentNote = this.currentNote.replace(this.draggedFolder, newPath); + if (this.currentNote && this.currentNote.startsWith(oldPrefix)) { + this.currentNote = this.currentNote.replace(oldPrefix, newPrefix); } } else { - alert('Failed to move folder.'); + const errorData = await response.json().catch(() => ({})); + alert(errorData.detail || 'Failed to move folder.'); } } catch (error) { console.error('Failed to move folder:', error); alert('Failed to move folder.'); } - - this.draggedFolder = null; this.dropTarget = null; } }, @@ -2562,9 +2598,25 @@ function noteApp() { this.expandedFolders.add(newPath); } + // Update favorites that were in the renamed folder + const folderPrefix = folderPath + '/'; + const newFolderPrefix = newPath + '/'; + const newFavorites = this.favorites.map(f => { + if (f.startsWith(folderPrefix)) { + return f.replace(folderPrefix, newFolderPrefix); + } + return f; + }); + // Check if anything changed + if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) { + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + // Update current note path if it's in the renamed folder - if (this.currentNote && this.currentNote.startsWith(folderPath + '/')) { - this.currentNote = this.currentNote.replace(folderPath, newPath); + if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { + this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix); } await this.loadNotes(); @@ -2599,8 +2651,17 @@ function noteApp() { // Remove from expanded folders this.expandedFolders.delete(folderPath); + // Remove any favorites that were in the deleted folder + const folderPrefix = folderPath + '/'; + const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix)); + if (newFavorites.length !== this.favorites.length) { + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + // Clear current note if it was in the deleted folder - if (this.currentNote && this.currentNote.startsWith(folderPath + '/')) { + if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { this.currentNote = ''; this.noteContent = ''; } @@ -2914,6 +2975,14 @@ function noteApp() { // Delete old note await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' }); + // Update favorites if the renamed note was favorited + if (this.favoritesSet.has(oldPath)) { + const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f); + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + this.currentNote = newPath; await this.loadNotes(); } else { @@ -2942,6 +3011,14 @@ function noteApp() { }); if (response.ok) { + // Remove from favorites if it was favorited + if (this.favoritesSet.has(notePath)) { + const newFavorites = this.favorites.filter(f => f !== notePath); + this.favorites = newFavorites; + this.favoritesSet = new Set(newFavorites); + this.saveFavorites(); + } + // If the deleted note is currently open, clear it if (this.currentNote === notePath) { this.currentNote = ''; diff --git a/frontend/index.html b/frontend/index.html index 4419d41..bb34c5b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1262,6 +1262,7 @@ class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer group" :class="{'bg-opacity-50': currentNote === fav.path}" :style="currentNote === fav.path ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'" + :title="fav.path" @mouseenter="if (currentNote !== fav.path) $el.style.backgroundColor = 'var(--bg-hover)'" @mouseleave="if (currentNote !== fav.path) $el.style.backgroundColor = 'transparent'" >