optimistic vault operations => more speed
This commit is contained in:
parent
e9b88516ae
commit
f8f697ea59
463
frontend/app.js
463
frontend/app.js
|
|
@ -1529,26 +1529,26 @@ function noteApp() {
|
||||||
|
|
||||||
// ==================== END INTERNATIONALIZATION ====================
|
// ==================== END INTERNATIONALIZATION ====================
|
||||||
|
|
||||||
// Load all notes
|
// Load all notes. Pass {silent: true} from error-recovery paths so the
|
||||||
async loadNotes() {
|
// 800ms loading overlay never appears on background re-syncs.
|
||||||
|
async loadNotes({ silent = false } = {}) {
|
||||||
this.notesLoading = true;
|
this.notesLoading = true;
|
||||||
// Show the overlay only if we're still loading after 800ms AND
|
|
||||||
// the tree is currently empty (first load). Refreshes after a
|
|
||||||
// save typically resolve in <100ms and never trip this.
|
|
||||||
clearTimeout(this._notesLoadingOverlayTimer);
|
clearTimeout(this._notesLoadingOverlayTimer);
|
||||||
|
if (!silent) {
|
||||||
this._notesLoadingOverlayTimer = setTimeout(() => {
|
this._notesLoadingOverlayTimer = setTimeout(() => {
|
||||||
if (this.notesLoading && this.notes.length === 0 && this.allFolders.length === 0) {
|
if (this.notesLoading && this.notes.length === 0 && this.allFolders.length === 0) {
|
||||||
this.notesLoadingShowOverlay = true;
|
this.notesLoadingShowOverlay = true;
|
||||||
}
|
}
|
||||||
}, 800);
|
}, 800);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/notes');
|
const response = await fetch('/api/notes');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.notes = data.notes;
|
this.notes = data.notes;
|
||||||
this.allFolders = data.folders || [];
|
this.allFolders = data.folders || [];
|
||||||
this.buildNoteLookupMaps(); // Build O(1) lookup maps
|
this.buildNoteLookupMaps();
|
||||||
this.buildFolderTree();
|
this.buildFolderTree();
|
||||||
await this.loadTags(); // Load tags after notes are loaded
|
await this.loadTags();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('load notes', error);
|
ErrorHandler.handle('load notes', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1759,7 +1759,15 @@ function noteApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create note from template
|
// Optimistic stub: add empty entry so sidebar updates instantly.
|
||||||
|
// Server rendering may inject content/tags; loadNote() fetches the
|
||||||
|
// real body and tags are refreshed via loadTagsDebounced below.
|
||||||
|
this._optimisticAddNote(notePath, { content: '' });
|
||||||
|
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
||||||
|
if (folderPart) this.expandedFolders.add(folderPart);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch('/api/templates/create-note', {
|
const response = await fetch('/api/templates/create-note', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -1768,28 +1776,26 @@ function noteApp() {
|
||||||
notePath: notePath
|
notePath: notePath
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' });
|
throw new Error(error.detail || this.t('templates.create_failed'));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
this.lastUsedTemplate = this.selectedTemplate;
|
this.lastUsedTemplate = this.selectedTemplate;
|
||||||
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
|
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
|
||||||
|
|
||||||
// Close modal and reset state
|
|
||||||
this.showTemplateModal = false;
|
this.showTemplateModal = false;
|
||||||
this.selectedTemplate = '';
|
this.selectedTemplate = '';
|
||||||
this.newTemplateNoteName = '';
|
this.newTemplateNoteName = '';
|
||||||
|
|
||||||
// Reload notes and open the new note
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadNote(data.path);
|
await this.loadNote(data.path);
|
||||||
this.focusEditorForNewNote();
|
this.focusEditorForNewNote();
|
||||||
|
this.loadTagsDebounced();
|
||||||
|
} catch (err) {
|
||||||
|
this.toast(err.message || this.t('templates.create_failed'), { type: 'error' });
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('create note from template', error);
|
ErrorHandler.handle('create note from template', error);
|
||||||
}
|
}
|
||||||
|
|
@ -2280,6 +2286,108 @@ function noteApp() {
|
||||||
this.folderTree = tree;
|
this.folderTree = tree;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// OPTIMISTIC MUTATION HELPERS
|
||||||
|
// Mirror server-side index updates locally so file ops feel instant on
|
||||||
|
// big vaults. Mutations apply the change to this.notes/this.allFolders
|
||||||
|
// immediately, fire the request, and on error fall back to
|
||||||
|
// loadNotes({silent: true}) to resync from disk.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_isoNow() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
|
||||||
|
_folderFromPath(path) {
|
||||||
|
const i = path.lastIndexOf('/');
|
||||||
|
return i === -1 ? '' : path.substring(0, i);
|
||||||
|
},
|
||||||
|
|
||||||
|
_filenameFromPath(path) {
|
||||||
|
return path.split('/').pop();
|
||||||
|
},
|
||||||
|
|
||||||
|
_inferTypeFromPath(path) {
|
||||||
|
const m = /\.([^./]+)$/.exec(path);
|
||||||
|
const ext = m ? m[1].toLowerCase() : '';
|
||||||
|
if (ext === 'md' || ext === '') return 'note';
|
||||||
|
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return 'image';
|
||||||
|
if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio';
|
||||||
|
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return 'video';
|
||||||
|
if (ext === 'pdf') return 'document';
|
||||||
|
return 'note';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh sidebar tree + wikilink lookup maps after any optimistic update.
|
||||||
|
_rebuildTreeAfterMutation() {
|
||||||
|
this.buildNoteLookupMaps();
|
||||||
|
this.buildFolderTree();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a note/media file to the local list. No-op if already present.
|
||||||
|
_optimisticAddNote(path, { content = '', type = null, size = null } = {}) {
|
||||||
|
if (this.notes.some(n => n.path === path)) return;
|
||||||
|
const inferredType = type || this._inferTypeFromPath(path);
|
||||||
|
const filename = this._filenameFromPath(path);
|
||||||
|
const name = inferredType === 'note' ? filename.replace(/\.md$/i, '') : filename;
|
||||||
|
this.notes.push({
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
folder: this._folderFromPath(path),
|
||||||
|
type: inferredType,
|
||||||
|
size: size != null ? size : (content ? new Blob([content]).size : 0),
|
||||||
|
modified: this._isoNow(),
|
||||||
|
tags: (inferredType === 'note' && content) ? this.parseTagsFromContent(content) : [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_optimisticRemoveNote(path) {
|
||||||
|
this.notes = this.notes.filter(n => n.path !== path);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Used by single-note rename and move (path changes, identity preserved).
|
||||||
|
_optimisticRenameNote(oldPath, newPath) {
|
||||||
|
const note = this.notes.find(n => n.path === oldPath);
|
||||||
|
if (!note) return;
|
||||||
|
note.path = newPath;
|
||||||
|
note.folder = this._folderFromPath(newPath);
|
||||||
|
const filename = this._filenameFromPath(newPath);
|
||||||
|
note.name = note.type === 'note' ? filename.replace(/\.md$/i, '') : filename;
|
||||||
|
},
|
||||||
|
|
||||||
|
_optimisticAddFolder(folderPath) {
|
||||||
|
if (!folderPath) return;
|
||||||
|
if (!this.allFolders.includes(folderPath)) {
|
||||||
|
this.allFolders.push(folderPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cascade: remove the folder, its descendant folders, and every note inside.
|
||||||
|
_optimisticRemoveFolderTree(folderPath) {
|
||||||
|
const prefix = folderPath + '/';
|
||||||
|
this.allFolders = this.allFolders.filter(f => f !== folderPath && !f.startsWith(prefix));
|
||||||
|
this.notes = this.notes.filter(n => !n.path.startsWith(prefix));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cascade: rename the folder, its descendant folders, and rewrite paths
|
||||||
|
// of every note inside.
|
||||||
|
_optimisticRenameFolderTree(oldPath, newPath) {
|
||||||
|
if (oldPath === newPath) return;
|
||||||
|
const oldPrefix = oldPath + '/';
|
||||||
|
const newPrefix = newPath + '/';
|
||||||
|
this.allFolders = this.allFolders.map(f => {
|
||||||
|
if (f === oldPath) return newPath;
|
||||||
|
if (f.startsWith(oldPrefix)) return newPrefix + f.substring(oldPrefix.length);
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
this.notes.forEach(n => {
|
||||||
|
if (n.path.startsWith(oldPrefix)) {
|
||||||
|
n.path = newPrefix + n.path.substring(oldPrefix.length);
|
||||||
|
n.folder = this._folderFromPath(n.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// DATA-ATTRIBUTE BASED HANDLERS
|
// DATA-ATTRIBUTE BASED HANDLERS
|
||||||
// These read path/name/type from data-* attributes, avoiding JS escaping issues
|
// These read path/name/type from data-* attributes, avoiding JS escaping issues
|
||||||
|
|
@ -2795,23 +2903,17 @@ function noteApp() {
|
||||||
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
|
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploaded = false;
|
|
||||||
for (const file of mediaFiles) {
|
for (const file of mediaFiles) {
|
||||||
try {
|
try {
|
||||||
const mediaPath = await this.uploadMedia(file, notePath);
|
const mediaPath = await this.uploadMedia(file, notePath);
|
||||||
if (mediaPath) {
|
if (mediaPath && this.currentNote) {
|
||||||
uploaded = true;
|
|
||||||
if (this.currentNote) {
|
|
||||||
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
|
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle(`upload file ${file.name}`, error);
|
ErrorHandler.handle(`upload file ${file.name}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uploaded && !this.currentNote) {
|
// uploadMedia already injects the file into this.notes optimistically.
|
||||||
await this.loadNotes();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Upload a media file (image, audio, video, PDF)
|
// Upload a media file (image, audio, video, PDF)
|
||||||
|
|
@ -2828,49 +2930,38 @@ function noteApp() {
|
||||||
formData.append('note_path', notePath || '');
|
formData.append('note_path', notePath || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/upload-media', {
|
const response = await fetch('/api/upload-media', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.detail || 'Upload failed');
|
throw new Error(error.detail || 'Upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.path;
|
// Drop the new file into the local note list so the wikilink
|
||||||
} catch (error) {
|
// resolver finds it without a full /api/notes refresh.
|
||||||
throw error;
|
if (data.path) {
|
||||||
|
this._optimisticAddNote(data.path, { size: file.size });
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
}
|
}
|
||||||
|
return data.path;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Insert media markdown at cursor position using wiki-style syntax
|
// Insert media markdown at cursor position using wiki-style syntax
|
||||||
// This ensures media links don't break when notes are moved
|
// (media links don't break when notes are moved). The uploaded file is
|
||||||
|
// already in this.notes thanks to uploadMedia()'s optimistic add.
|
||||||
async insertMediaMarkdown(mediaPath, altText, cursorPos) {
|
async insertMediaMarkdown(mediaPath, altText, cursorPos) {
|
||||||
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
|
|
||||||
const filename = mediaPath.split('/').pop();
|
const filename = mediaPath.split('/').pop();
|
||||||
|
|
||||||
// Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
|
|
||||||
// The alt text is optional - only add if different from filename
|
|
||||||
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
||||||
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
|
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
|
||||||
|
|
||||||
// If alt text is meaningful (not just "pasted-image"), include it
|
|
||||||
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
|
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
|
||||||
? `![[${filename}|${altWithoutExt}]]`
|
? `![[${filename}|${altWithoutExt}]]`
|
||||||
: `![[${filename}]]`;
|
: `![[${filename}]]`;
|
||||||
|
|
||||||
// Reload notes FIRST to update image lookup maps before preview renders
|
|
||||||
await this.loadNotes();
|
|
||||||
|
|
||||||
const textBefore = this.noteContent.substring(0, cursorPos);
|
const textBefore = this.noteContent.substring(0, cursorPos);
|
||||||
const textAfter = this.noteContent.substring(cursorPos);
|
const textAfter = this.noteContent.substring(cursorPos);
|
||||||
|
|
||||||
this.noteContent = textBefore + markdown + '\n' + textAfter;
|
this.noteContent = textBefore + markdown + '\n' + textAfter;
|
||||||
|
|
||||||
// Trigger autosave
|
|
||||||
this.autoSave();
|
this.autoSave();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -3010,23 +3101,18 @@ function noteApp() {
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
|
this._optimisticRemoveNote(mediaPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
if (this.currentMedia === mediaPath) this.currentMedia = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
|
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete media file');
|
||||||
if (response.ok) {
|
|
||||||
await this.loadNotes(); // Refresh tree
|
|
||||||
|
|
||||||
// Clear viewer if deleting currently viewed media
|
|
||||||
if (this.currentMedia === mediaPath) {
|
|
||||||
this.currentMedia = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to delete media file');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('delete media', error);
|
ErrorHandler.handle('delete media', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -3068,10 +3154,14 @@ function noteApp() {
|
||||||
nextToNotes: true,
|
nextToNotes: true,
|
||||||
contentFolder: targetFolder,
|
contentFolder: targetFolder,
|
||||||
});
|
});
|
||||||
await this.loadNotes();
|
// Server returns the final upload path (may differ from
|
||||||
|
// 'drawing.png' if it added a timestamp suffix).
|
||||||
|
this._optimisticAddNote(path, { type: 'image', size: blob.size });
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
this.viewMedia(path, 'drawing');
|
this.viewMedia(path, 'drawing');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('create drawing', error);
|
ErrorHandler.handle('create drawing', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -3962,7 +4052,13 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
throw new Error(detail || res.statusText);
|
throw new Error(detail || res.statusText);
|
||||||
}
|
}
|
||||||
await this.loadNotes();
|
// Drawing file already in this.notes — only metadata changed
|
||||||
|
// (size/mtime). Bump locally instead of a full /api/notes scan.
|
||||||
|
const rec = this.notes.find(n => n.path === this.currentMedia);
|
||||||
|
if (rec) {
|
||||||
|
rec.size = blob.size;
|
||||||
|
rec.modified = this._isoNow();
|
||||||
|
}
|
||||||
this.lastSaved = true;
|
this.lastSaved = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.lastSaved = false;
|
this.lastSaved = false;
|
||||||
|
|
@ -4129,19 +4225,13 @@ function noteApp() {
|
||||||
|
|
||||||
if (newPath === draggedPath) return;
|
if (newPath === draggedPath) return;
|
||||||
|
|
||||||
// Capture favorites info before async call
|
|
||||||
const oldPrefix = draggedPath + '/';
|
const oldPrefix = draggedPath + '/';
|
||||||
const newPrefix = newPath + '/';
|
const newPrefix = newPath + '/';
|
||||||
|
const wasExpanded = this.expandedFolders.has(draggedPath);
|
||||||
|
|
||||||
try {
|
this._optimisticRenameFolderTree(draggedPath, newPath);
|
||||||
const response = await fetch('/api/folders/move', {
|
this._rebuildTreeAfterMutation();
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Update favorites for notes inside moved folder
|
|
||||||
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
|
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
|
||||||
if (favoritesInFolder.length > 0) {
|
if (favoritesInFolder.length > 0) {
|
||||||
const newFavorites = this.favorites.map(f =>
|
const newFavorites = this.favorites.map(f =>
|
||||||
|
|
@ -4151,25 +4241,30 @@ function noteApp() {
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep folder expanded if it was
|
|
||||||
const wasExpanded = this.expandedFolders.has(draggedPath);
|
|
||||||
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadSharedNotePaths();
|
|
||||||
|
|
||||||
if (wasExpanded) {
|
if (wasExpanded) {
|
||||||
this.expandedFolders.delete(draggedPath);
|
this.expandedFolders.delete(draggedPath);
|
||||||
this.expandedFolders.add(newPath);
|
this.expandedFolders.add(newPath);
|
||||||
this.saveExpandedFolders();
|
this.saveExpandedFolders();
|
||||||
}
|
}
|
||||||
} else {
|
if (this.currentNote && this.currentNote.startsWith(oldPrefix)) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
this.currentNote = newPrefix + this.currentNote.substring(oldPrefix.length);
|
||||||
this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/folders/move', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || this.t('move.failed_folder'));
|
||||||
|
}
|
||||||
|
await this.loadSharedNotePaths();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to move folder:', error);
|
console.error('Failed to move folder:', error);
|
||||||
this.toast(this.t('move.failed_folder'), { type: 'error' });
|
this.toast(error.message || this.t('move.failed_folder'), { type: 'error' });
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4183,47 +4278,38 @@ function noteApp() {
|
||||||
|
|
||||||
if (newPath === draggedPath) return;
|
if (newPath === draggedPath) return;
|
||||||
|
|
||||||
// Check if note is favorited (only for notes)
|
|
||||||
const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
|
const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
|
||||||
|
const wasCurrentNote = this.currentNote === draggedPath;
|
||||||
|
const wasCurrentMedia = this.currentMedia === draggedPath;
|
||||||
|
|
||||||
|
this._optimisticRenameNote(draggedPath, newPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
if (wasFavorited) {
|
||||||
|
this.favorites = this.favorites.map(f => f === draggedPath ? newPath : f);
|
||||||
|
this.favoritesSet = new Set(this.favorites);
|
||||||
|
this.saveFavorites();
|
||||||
|
}
|
||||||
|
if (wasCurrentNote) this.currentNote = newPath;
|
||||||
|
if (wasCurrentMedia) this.currentMedia = newPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use different endpoint for media vs notes
|
|
||||||
const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
|
const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
if (response.ok) {
|
|
||||||
// Update favorites if the moved note was favorited
|
|
||||||
if (wasFavorited) {
|
|
||||||
const newFavorites = this.favorites.map(f => f === draggedPath ? newPath : f);
|
|
||||||
this.favorites = newFavorites;
|
|
||||||
this.favoritesSet = new Set(newFavorites);
|
|
||||||
this.saveFavorites();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep current item open if it was the moved one
|
|
||||||
const wasCurrentNote = this.currentNote === draggedPath;
|
|
||||||
const wasCurrentMedia = this.currentMedia === draggedPath;
|
|
||||||
|
|
||||||
await this.loadNotes();
|
|
||||||
if (isNote) {
|
|
||||||
await this.loadSharedNotePaths();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wasCurrentNote) this.currentNote = newPath;
|
|
||||||
if (wasCurrentMedia) this.currentMedia = newPath;
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
|
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
|
||||||
this.toast(errorData.detail || this.t(errorKey), { type: 'error' });
|
throw new Error(errorData.detail || this.t(errorKey));
|
||||||
}
|
}
|
||||||
|
if (isNote) await this.loadSharedNotePaths();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
|
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
|
||||||
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
|
this.toast(error.message || this.t(isMedia ? 'move.failed_media' : 'move.failed_note'), { type: 'error' });
|
||||||
this.toast(this.t(errorKey), { type: 'error' });
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -4833,50 +4919,48 @@ function noteApp() {
|
||||||
},
|
},
|
||||||
|
|
||||||
async _finalizeCreateNote(notePath) {
|
async _finalizeCreateNote(notePath) {
|
||||||
|
// Optimistic add — sidebar reflects the new note instantly. Server
|
||||||
|
// confirms; on failure we resync silently.
|
||||||
|
this._optimisticAddNote(notePath, { content: '' });
|
||||||
|
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
||||||
|
if (folderPart) this.expandedFolders.add(folderPart);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${notePath}`, {
|
const response = await fetch(`/api/notes/${notePath}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: '' })
|
body: JSON.stringify({ content: '' })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
if (response.ok) {
|
|
||||||
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
|
||||||
if (folderPart) this.expandedFolders.add(folderPart);
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadNote(notePath);
|
await this.loadNote(notePath);
|
||||||
this.focusEditorForNewNote();
|
this.focusEditorForNewNote();
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
ErrorHandler.handle('create note', new Error('Server returned error'));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('create note', error);
|
ErrorHandler.handle('create note', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async _finalizeCreateFolder(folderPath, targetFolder) {
|
async _finalizeCreateFolder(folderPath, targetFolder) {
|
||||||
|
this._optimisticAddFolder(folderPath);
|
||||||
|
if (targetFolder) this.expandedFolders.add(targetFolder);
|
||||||
|
this.expandedFolders.add(folderPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/folders', {
|
const response = await fetch('/api/folders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path: folderPath })
|
body: JSON.stringify({ path: folderPath })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
if (response.ok) {
|
|
||||||
if (targetFolder) {
|
|
||||||
this.expandedFolders.add(targetFolder);
|
|
||||||
}
|
|
||||||
this.expandedFolders.add(folderPath);
|
|
||||||
await this.loadNotes();
|
|
||||||
this.goToHomepageFolder(folderPath);
|
this.goToHomepageFolder(folderPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
ErrorHandler.handle('create folder', new Error('Server returned error'));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('create folder', error);
|
ErrorHandler.handle('create folder', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4973,90 +5057,80 @@ function noteApp() {
|
||||||
},
|
},
|
||||||
|
|
||||||
async _finalizeRenameFolder(folderPath, newPath) {
|
async _finalizeRenameFolder(folderPath, newPath) {
|
||||||
try {
|
const folderPrefix = folderPath + '/';
|
||||||
const response = await fetch('/api/folders/rename', {
|
const newFolderPrefix = newPath + '/';
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Optimistic cascade: rewrite folder + every nested folder + every
|
||||||
body: JSON.stringify({
|
// note path. Sidebar reflects the rename instantly.
|
||||||
oldPath: folderPath,
|
this._optimisticRenameFolderTree(folderPath, newPath);
|
||||||
newPath: newPath
|
this._rebuildTreeAfterMutation();
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (this.expandedFolders.has(folderPath)) {
|
if (this.expandedFolders.has(folderPath)) {
|
||||||
this.expandedFolders.delete(folderPath);
|
this.expandedFolders.delete(folderPath);
|
||||||
this.expandedFolders.add(newPath);
|
this.expandedFolders.add(newPath);
|
||||||
}
|
}
|
||||||
|
const newFavorites = this.favorites.map(f =>
|
||||||
const folderPrefix = folderPath + '/';
|
f.startsWith(folderPrefix) ? newFolderPrefix + f.substring(folderPrefix.length) : f
|
||||||
const newFolderPrefix = newPath + '/';
|
);
|
||||||
const newFavorites = this.favorites.map(f => {
|
|
||||||
if (f.startsWith(folderPrefix)) {
|
|
||||||
return f.replace(folderPrefix, newFolderPrefix);
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
});
|
|
||||||
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
|
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
|
||||||
this.favorites = newFavorites;
|
this.favorites = newFavorites;
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
||||||
this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
|
this.currentNote = newFolderPrefix + this.currentNote.substring(folderPrefix.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
|
const response = await fetch('/api/folders/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldPath: folderPath, newPath: newPath })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
ErrorHandler.handle('rename folder', new Error('Server returned error'));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('rename folder', error);
|
ErrorHandler.handle('rename folder', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete folder
|
// Delete folder (cascade: every nested folder + note)
|
||||||
async deleteFolder(folderPath, folderName) {
|
async deleteFolder(folderPath, folderName) {
|
||||||
const ok = await this.confirmModalAsk({
|
const ok = await this.confirmModalAsk({
|
||||||
message: this.t('folders.confirm_delete', { name: folderName }),
|
message: this.t('folders.confirm_delete', { name: folderName }),
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Remove from expanded folders
|
|
||||||
this.expandedFolders.delete(folderPath);
|
|
||||||
|
|
||||||
// Remove any favorites that were in the deleted folder
|
|
||||||
const folderPrefix = folderPath + '/';
|
const folderPrefix = folderPath + '/';
|
||||||
|
|
||||||
|
this._optimisticRemoveFolderTree(folderPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
this.expandedFolders.delete(folderPath);
|
||||||
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
|
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
|
||||||
if (newFavorites.length !== this.favorites.length) {
|
if (newFavorites.length !== this.favorites.length) {
|
||||||
this.favorites = newFavorites;
|
this.favorites = newFavorites;
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear current note if it was in the deleted folder
|
|
||||||
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
document.title = this.appName;
|
document.title = this.appName;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
} else {
|
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
|
||||||
ErrorHandler.handle('delete folder', new Error('Server returned error'));
|
method: 'DELETE',
|
||||||
}
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
|
this.loadTagsDebounced();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('delete folder', error);
|
ErrorHandler.handle('delete folder', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -5500,33 +5574,29 @@ function noteApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new note with same content
|
// Optimistic rename: rewrite local path now + favorites + current
|
||||||
|
// note pointer + URL. POST new content, then DELETE old.
|
||||||
|
this._optimisticRenameNote(oldPath, newPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
if (this.favoritesSet.has(oldPath)) {
|
||||||
|
this.favorites = this.favorites.map(f => f === oldPath ? newPath : f);
|
||||||
|
this.favoritesSet = new Set(this.favorites);
|
||||||
|
this.saveFavorites();
|
||||||
|
}
|
||||||
|
this.currentNote = newPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/notes/${newPath}`, {
|
const response = await fetch(`/api/notes/${newPath}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: this.noteContent })
|
body: JSON.stringify({ content: this.noteContent })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
if (response.ok) {
|
|
||||||
// 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;
|
|
||||||
await this.loadNotes();
|
|
||||||
} else {
|
|
||||||
ErrorHandler.handle('rename note', new Error('Server returned error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('rename note', error);
|
ErrorHandler.handle('rename note', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -5545,39 +5615,36 @@ function noteApp() {
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
// Optimistic: remove locally + clear current note + drop favorite
|
||||||
const response = await fetch(`/api/notes/${notePath}`, {
|
// before the network round-trip. Sidebar refreshes in <1ms on any
|
||||||
method: 'DELETE'
|
// vault size. On error we resync silently from disk.
|
||||||
});
|
this._optimisticRemoveNote(notePath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Remove from favorites if it was favorited
|
|
||||||
if (this.favoritesSet.has(notePath)) {
|
if (this.favoritesSet.has(notePath)) {
|
||||||
const newFavorites = this.favorites.filter(f => f !== notePath);
|
this.favorites = this.favorites.filter(f => f !== notePath);
|
||||||
this.favorites = newFavorites;
|
this.favoritesSet = new Set(this.favorites);
|
||||||
this.favoritesSet = new Set(newFavorites);
|
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the deleted note is currently open, clear it
|
|
||||||
if (this.currentNote === notePath) {
|
if (this.currentNote === notePath) {
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this._lastRenderedContent = ''; // Clear render cache
|
this._lastRenderedContent = '';
|
||||||
this._lastRenderedNote = '';
|
this._lastRenderedNote = '';
|
||||||
this._cachedRenderedHTML = '';
|
this._cachedRenderedHTML = '';
|
||||||
document.title = this.appName;
|
document.title = this.appName;
|
||||||
// Redirect to root
|
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
} else {
|
const response = await fetch(`/api/notes/${notePath}`, { method: 'DELETE' });
|
||||||
ErrorHandler.handle('delete note', new Error('Server returned error'));
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
}
|
this.loadTagsDebounced();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('delete note', error);
|
ErrorHandler.handle('delete note', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue