Merge branch 'main' into features/i8n

# Conflicts:
#	frontend/app.js
This commit is contained in:
Gamosoft 2025-12-15 12:35:33 +01:00
commit 83e9ebbd3d
5 changed files with 161 additions and 67 deletions

View File

@ -1 +1 @@
0.8.1 0.8.2

View File

@ -685,10 +685,10 @@ async def move_note_endpoint(request: Request, data: dict):
if not old_path or not new_path: if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required") 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: 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 # Run plugin hooks
plugin_manager.run_hook('on_note_save', note_path=new_path, content='') plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
@ -716,10 +716,10 @@ async def move_folder_endpoint(request: Request, data: dict):
if not old_path or not new_path: if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required") 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: 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 { return {
"success": True, "success": True,
@ -744,10 +744,10 @@ async def rename_folder_endpoint(request: Request, data: dict):
if not old_path or not new_path: if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required") 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: 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 { return {
"success": True, "success": True,

View File

@ -73,57 +73,70 @@ def get_all_folders(notes_dir: str) -> List[str]:
return sorted(folders) return sorted(folders)
def move_note(notes_dir: str, old_path: str, new_path: str) -> bool: def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
"""Move a note to a different location""" """Move a note to a different location
Returns:
Tuple of (success: bool, error_message: str)
"""
old_full_path = Path(notes_dir) / old_path old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path new_full_path = Path(notes_dir) / new_path
# Security checks # Security checks
if not validate_path_security(notes_dir, old_full_path) or \ if not validate_path_security(notes_dir, old_full_path):
not validate_path_security(notes_dir, new_full_path): return False, "Invalid source path"
return False if not validate_path_security(notes_dir, new_full_path):
return False, "Invalid destination path"
if not old_full_path.exists(): 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) # Check if target already exists (prevent overwriting)
if new_full_path.exists(): if new_full_path.exists():
return False return False, f"A note already exists at: {new_path}"
# Invalidate cache for old path # Invalidate cache for old path
old_key = str(old_full_path) old_key = str(old_full_path)
if old_key in _tag_cache: if old_key in _tag_cache:
del _tag_cache[old_key] del _tag_cache[old_key]
# Create parent directory if needed try:
new_full_path.parent.mkdir(parents=True, exist_ok=True) # Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the file # Move the file
old_full_path.rename(new_full_path) 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 # 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: def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
"""Move a folder to a different location""" """Move a folder to a different location
Returns:
Tuple of (success: bool, error_message: str)
"""
import shutil import shutil
old_full_path = Path(notes_dir) / old_path old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path new_full_path = Path(notes_dir) / new_path
# Security checks # Security checks
if not validate_path_security(notes_dir, old_full_path) or \ if not validate_path_security(notes_dir, old_full_path):
not validate_path_security(notes_dir, new_full_path): return False, "Invalid source path"
return False 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(): 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 # Check if target already exists
if new_full_path.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 # Invalidate cache for all notes in this folder
global _tag_cache 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: for key in keys_to_delete:
del _tag_cache[key] del _tag_cache[key]
# Create parent directory if needed try:
new_full_path.parent.mkdir(parents=True, exist_ok=True) # Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the folder # Move the folder
shutil.move(str(old_full_path), str(new_full_path)) 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 # 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)""" """Rename a folder (same as move but for clarity)"""
return move_folder(notes_dir, old_path, new_path) return move_folder(notes_dir, old_path, new_path)

View File

@ -1170,14 +1170,13 @@ function noteApp() {
if (this.favoritesSet.has(path)) { if (this.favoritesSet.has(path)) {
// Remove from favorites // Remove from favorites
this.favoritesSet.delete(path);
this.favorites = this.favorites.filter(f => f !== path); this.favorites = this.favorites.filter(f => f !== path);
} else { } else {
// Add to favorites // Add to favorites
this.favoritesSet.add(path); this.favorites = [...this.favorites, path];
this.favorites.push(path);
} }
// Recreate Set from array for consistency
this.favoritesSet = new Set(this.favorites);
this.saveFavorites(); this.saveFavorites();
}, },
@ -1185,10 +1184,14 @@ function noteApp() {
get favoriteNotes() { get favoriteNotes() {
return this.favorites return this.favorites
.map(path => { .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; if (!note) return null;
return { return {
path: note.path, path: note.path, // Use actual path from notes (fixes case issues)
name: note.path.split('/').pop().replace('.md', ''), name: note.path.split('/').pop().replace('.md', ''),
folder: note.folder || '' folder: note.folder || ''
}; };
@ -2040,15 +2043,18 @@ function noteApp() {
return; 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 // Handle note drop into folder
if (this.draggedNote) { if (draggedNotePath) {
const note = this.notes.find(n => n.path === this.draggedNote); const note = this.notes.find(n => n.path === draggedNotePath);
if (!note) return; if (!note) return;
// Don't allow moving images to folders // Don't allow moving images to folders
if (note.type === 'image') { if (note.type === 'image') {
this.draggedNote = null;
this.draggedItem = null;
return; return;
} }
@ -2056,82 +2062,112 @@ function noteApp() {
const filename = note.path.split('/').pop(); const filename = note.path.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
if (newPath === this.draggedNote) { if (newPath === draggedNotePath) {
this.draggedNote = null;
return; return;
} }
// Check if this note is favorited BEFORE the async call
const wasFavorited = this.favoritesSet.has(draggedNotePath);
try { try {
const response = await fetch('/api/notes/move', { const response = await fetch('/api/notes/move', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
oldPath: this.draggedNote, oldPath: draggedNotePath,
newPath: newPath newPath: newPath
}) })
}); });
if (response.ok) { 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 // 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; this.currentNote = newPath;
} }
} else { } else {
alert(this.t('move.failed_note')); const errorData = await response.json().catch(() => ({}));
alert(errorData.detail || this.t('move.failed_note'));
} }
} catch (error) { } catch (error) {
console.error('Failed to move note:', error); console.error('Failed to move note:', error);
alert(this.t('move.failed_note')); alert(this.t('move.failed_note'));
} }
this.draggedNote = null;
return; return;
} }
// Handle folder drop into folder // Handle folder drop into folder
if (this.draggedFolder) { if (draggedFolderPath) {
// Prevent dropping folder into itself or its subfolders // Prevent dropping folder into itself or its subfolders
if (targetFolderPath === this.draggedFolder || if (targetFolderPath === draggedFolderPath ||
targetFolderPath.startsWith(this.draggedFolder + '/')) { targetFolderPath.startsWith(draggedFolderPath + '/')) {
alert(this.t('folders.cannot_move_into_self')); alert(this.t('folders.cannot_move_into_self'));
this.draggedFolder = null;
return; return;
} }
const folderName = this.draggedFolder.split('/').pop(); const folderName = draggedFolderPath.split('/').pop();
const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName; const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
if (newPath === this.draggedFolder) { if (newPath === draggedFolderPath) {
this.draggedFolder = null;
return; return;
} }
// Capture favorites info before async call
const oldPrefix = draggedFolderPath + '/';
const newPrefix = newPath + '/';
try { try {
const response = await fetch('/api/folders/move', { const response = await fetch('/api/folders/move', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
oldPath: this.draggedFolder, oldPath: draggedFolderPath,
newPath: newPath newPath: newPath
}) })
}); });
if (response.ok) { 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(); await this.loadNotes();
// Update current note path if it was in the moved folder // Update current note path if it was in the moved folder
if (this.currentNote && this.currentNote.startsWith(this.draggedFolder + '/')) { if (this.currentNote && this.currentNote.startsWith(oldPrefix)) {
this.currentNote = this.currentNote.replace(this.draggedFolder, newPath); this.currentNote = this.currentNote.replace(oldPrefix, newPrefix);
} }
} else { } else {
alert(this.t('move.failed_folder')); const errorData = await response.json().catch(() => ({}));
alert(errorData.detail || this.t('move.failed_folder'));
} }
} catch (error) { } catch (error) {
console.error('Failed to move folder:', error); console.error('Failed to move folder:', error);
alert(this.t('move.failed_folder')); alert(this.t('move.failed_folder'));
} }
this.draggedFolder = null;
this.dropTarget = null; this.dropTarget = null;
} }
}, },
@ -2666,9 +2702,25 @@ function noteApp() {
this.expandedFolders.add(newPath); 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 // Update current note path if it's in the renamed folder
if (this.currentNote && this.currentNote.startsWith(folderPath + '/')) { if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = this.currentNote.replace(folderPath, newPath); this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
} }
await this.loadNotes(); await this.loadNotes();
@ -2696,8 +2748,17 @@ function noteApp() {
// Remove from expanded folders // Remove from expanded folders
this.expandedFolders.delete(folderPath); 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 // 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.currentNote = '';
this.noteContent = ''; this.noteContent = '';
} }
@ -3011,6 +3072,14 @@ function noteApp() {
// Delete old note // Delete old note
await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' }); 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; this.currentNote = newPath;
await this.loadNotes(); await this.loadNotes();
} else { } else {
@ -3039,6 +3108,14 @@ function noteApp() {
}); });
if (response.ok) { 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 the deleted note is currently open, clear it
if (this.currentNote === notePath) { if (this.currentNote === notePath) {
this.currentNote = ''; this.currentNote = '';

View File

@ -1262,6 +1262,7 @@
class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer group" class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer group"
:class="{'bg-opacity-50': currentNote === fav.path}" :class="{'bg-opacity-50': currentNote === fav.path}"
:style="currentNote === fav.path ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'" :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)'" @mouseenter="if (currentNote !== fav.path) $el.style.backgroundColor = 'var(--bg-hover)'"
@mouseleave="if (currentNote !== fav.path) $el.style.backgroundColor = 'transparent'" @mouseleave="if (currentNote !== fav.path) $el.style.backgroundColor = 'transparent'"
> >