diff --git a/src/App.tsx b/src/App.tsx index 193e2e5..e0efcfa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useState } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { LoginView } from './components/LoginView'; import { NotesList } from './components/NotesList'; import { NoteEditor } from './components/NoteEditor'; @@ -16,11 +16,82 @@ const LazyPrintView = lazy(async () => { return { default: module.PrintView }; }); +const AUTOSAVE_DELAY_MS = 1000; + +interface SaveController { + timerId: number | null; + revision: number; + inFlight: Promise | null; + inFlightRevision: number; +} + +interface FlushSaveOptions { + force?: boolean; +} + +const sortNotes = (notes: Note[]) => + [...notes].sort((a, b) => b.modified - a.modified); + +const toStoredNote = (note: Note): Note => ({ + ...note, + isSaving: false, +}); + +const getNoteDraftId = (note: Note | null | undefined) => note?.draftId ?? null; + +const createDraftId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +}; + +const getRemoteCategory = (note: Note) => { + if (note.path) { + const pathParts = note.path.split('/'); + return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''; + } + + if (typeof note.id === 'string') { + const idParts = note.id.split('/'); + return idParts.length > 1 ? idParts.slice(0, -1).join('/') : ''; + } + + return note.category; +}; + +const splitNoteContent = (content: string) => { + const [firstLine = '', ...rest] = content.split('\n'); + return { + title: firstLine.replace(/^#+\s*/, '').trim(), + body: rest.join('\n'), + }; +}; + +const canAutosaveLocalNote = (note: Note) => { + if (!note.localOnly) { + return true; + } + + const { title } = splitNoteContent(note.content); + return title.length > 0 && note.content.includes('\n'); +}; + +const canForceSaveLocalNote = (note: Note) => { + if (!note.localOnly) { + return true; + } + + const { title } = splitNoteContent(note.content); + return title.length > 0; +}; + function MainApp() { const [isLoggedIn, setIsLoggedIn] = useState(false); const [api, setApi] = useState(null); const [notes, setNotes] = useState([]); - const [selectedNoteId, setSelectedNoteId] = useState(null); + const [selectedNoteDraftId, setSelectedNoteDraftId] = useState(null); const [searchText, setSearchText] = useState(''); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [selectedCategory, setSelectedCategory] = useState(''); @@ -30,7 +101,6 @@ function MainApp() { const [username, setUsername] = useState(''); const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [editorFont, setEditorFont] = useState('Source Code Pro'); const [editorFontSize, setEditorFontSize] = useState(14); const [previewFont, setPreviewFont] = useState('Merriweather'); @@ -38,6 +108,89 @@ function MainApp() { const [syncStatus, setSyncStatus] = useState('idle'); const [pendingSyncCount, setPendingSyncCount] = useState(0); const isOnline = useOnlineStatus(); + const notesRef = useRef([]); + const saveControllersRef = useRef>(new Map()); + const savedSnapshotsRef = useRef>(new Map()); + + const setSortedNotes = (updater: Note[] | ((previous: Note[]) => Note[])) => { + setNotes((previous) => { + const nextNotes = typeof updater === 'function' + ? (updater as (previous: Note[]) => Note[])(previous) + : updater; + const sortedNotes = sortNotes(nextNotes); + notesRef.current = sortedNotes; + return sortedNotes; + }); + }; + + const getNoteByDraftId = (draftId: string | null) => + draftId ? notesRef.current.find(note => note.draftId === draftId) ?? null : null; + + const persistNoteToCache = (note: Note) => { + void localDB.saveNote(toStoredNote(note)).catch((error) => { + console.error('Failed to persist note locally:', error); + }); + }; + + const ensureSaveController = (draftId: string) => { + let controller = saveControllersRef.current.get(draftId); + if (!controller) { + controller = { + timerId: null, + revision: 0, + inFlight: null, + inFlightRevision: 0, + }; + saveControllersRef.current.set(draftId, controller); + } + + return controller; + }; + + const clearSaveTimer = (draftId: string) => { + const controller = saveControllersRef.current.get(draftId); + if (controller?.timerId) { + window.clearTimeout(controller.timerId); + controller.timerId = null; + } + }; + + const applyLoadedNotes = (loadedNotes: Note[]) => { + const normalizedNotes = sortNotes(loadedNotes); + const incomingDraftIds = new Set(); + + normalizedNotes.forEach((note) => { + if (!note.draftId) { + return; + } + + incomingDraftIds.add(note.draftId); + if (!note.pendingSave) { + savedSnapshotsRef.current.set(note.draftId, { + ...note, + pendingSave: false, + isSaving: false, + saveError: null, + }); + } + }); + + for (const draftId of Array.from(savedSnapshotsRef.current.keys())) { + if (!incomingDraftIds.has(draftId)) { + savedSnapshotsRef.current.delete(draftId); + } + } + + notesRef.current = normalizedNotes; + setNotes(normalizedNotes); + setSelectedNoteDraftId((current) => { + if (current && normalizedNotes.some(note => note.draftId === current)) { + return current; + } + + return getNoteDraftId(normalizedNotes[0]); + }); + }; useEffect(() => { const initApp = async () => { @@ -119,7 +272,7 @@ function MainApp() { // Reload notes from cache after background sync completes // Don't call loadNotes() as it triggers another sync - just reload from cache const cachedNotes = await localDB.getAllNotes(); - setNotes(cachedNotes.sort((a, b) => b.modified - a.modified)); + applyLoadedNotes(cachedNotes); }); }, []); @@ -131,13 +284,43 @@ function MainApp() { } }, [api, isLoggedIn]); + useEffect(() => { + if (!isLoggedIn || !isOnline) { + return; + } + + notesRef.current + .filter(note => note.pendingSave && note.draftId) + .forEach((note) => { + const draftId = note.draftId as string; + const controller = ensureSaveController(draftId); + if (!controller.inFlight && !controller.timerId) { + controller.timerId = window.setTimeout(() => { + controller.timerId = null; + void flushNoteSave(draftId); + }, 0); + } + }); + }, [isLoggedIn, isOnline]); + + useEffect(() => { + const handleBeforeUnload = () => { + notesRef.current + .filter(note => note.pendingSave && note.draftId) + .forEach((note) => { + clearSaveTimer(note.draftId!); + void flushNoteSave(note.draftId!, { force: true }); + }); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, []); + const loadNotes = async () => { try { const loadedNotes = await syncManager.loadNotes(); - setNotes(loadedNotes.sort((a, b) => b.modified - a.modified)); - if (!selectedNoteId && loadedNotes.length > 0) { - setSelectedNoteId(loadedNotes[0].id); - } + applyLoadedNotes(loadedNotes); } catch (error) { console.error('Failed to load notes:', error); } @@ -145,6 +328,7 @@ function MainApp() { const syncNotes = async () => { try { + await flushAllPendingSaves(); await syncManager.syncWithServer(); await loadNotes(); } catch (error) { @@ -175,8 +359,11 @@ function MainApp() { categoryColorsSync.setAPI(null); setUsername(''); setNotes([]); - setSelectedNoteId(null); + notesRef.current = []; + setSelectedNoteDraftId(null); setIsLoggedIn(false); + saveControllersRef.current.clear(); + savedSnapshotsRef.current.clear(); }; const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { @@ -205,25 +392,66 @@ function MainApp() { }; const handleToggleFavorite = async (note: Note, favorite: boolean) => { + const draftId = note.draftId; + if (!draftId) { + return; + } + + const optimisticNote = { + ...note, + favorite, + saveError: null, + }; + + setSortedNotes(previousNotes => + previousNotes.map(currentNote => + currentNote.draftId === draftId ? optimisticNote : currentNote + ) + ); + persistNoteToCache(optimisticNote); + try { - await syncManager.updateFavoriteStatus(note, favorite); - // Update local state - setNotes(prevNotes => - prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n) - ); + await syncManager.updateFavoriteStatus(optimisticNote, favorite); + const snapshot = savedSnapshotsRef.current.get(draftId); + if (snapshot) { + savedSnapshotsRef.current.set(draftId, { + ...snapshot, + favorite, + }); + } } catch (error) { console.error('Toggle favorite failed:', error); } }; const handleCreateNote = async () => { - try { - const note = await syncManager.createNote('New Note', '', selectedCategory); - setNotes([note, ...notes]); - setSelectedNoteId(note.id); - } catch (error) { - console.error('Create note failed:', error); - } + const draftId = createDraftId(); + const note: Note = { + id: `local:${draftId}`, + etag: '', + readonly: false, + content: '', + title: 'Untitled', + category: selectedCategory, + favorite: false, + modified: Math.floor(Date.now() / 1000), + draftId, + localOnly: true, + pendingSave: false, + isSaving: false, + saveError: null, + lastSavedAt: undefined, + }; + + savedSnapshotsRef.current.set(draftId, { + ...note, + pendingSave: false, + isSaving: false, + saveError: null, + }); + setSortedNotes(previousNotes => [note, ...previousNotes]); + persistNoteToCache(note); + setSelectedNoteDraftId(draftId); }; const handleCreateCategory = (name: string) => { @@ -239,7 +467,19 @@ function MainApp() { for (const note of notesToMove) { try { const movedNote = await syncManager.moveNote(note, newName); - setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n)); + if (movedNote.draftId) { + savedSnapshotsRef.current.set(movedNote.draftId, { + ...movedNote, + pendingSave: false, + isSaving: false, + saveError: null, + }); + } + setSortedNotes(previousNotes => + previousNotes.map(currentNote => + currentNote.draftId === note.draftId ? movedNote : currentNote + ) + ); } catch (error) { console.error(`Failed to move note ${note.id}:`, error); } @@ -256,53 +496,280 @@ function MainApp() { } }; - const handleUpdateNote = async (updatedNote: Note) => { - try { - const originalNote = notes.find(n => n.id === updatedNote.id); - - // If category changed, use moveNote instead of updateNote - if (originalNote && originalNote.category !== updatedNote.category) { - const movedNote = await syncManager.moveNote(originalNote, updatedNote.category); - // If content/title also changed, update the moved note - if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) { - const finalNote = await syncManager.updateNote({ - ...movedNote, - title: updatedNote.title, - content: updatedNote.content, - favorite: updatedNote.favorite, - }); - setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n)); - // Update selected note ID if it changed - if (selectedNoteId === originalNote.id && finalNote.id !== originalNote.id) { - setSelectedNoteId(finalNote.id); - } - } else { - setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n)); - // Update selected note ID if it changed - if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) { - setSelectedNoteId(movedNote.id); - } - } - } else { - const updated = await syncManager.updateNote(updatedNote); - setNotes(notes.map(n => n.id === updatedNote.id ? updated : n)); - // Update selected note ID if it changed (e.g., filename changed due to first line edit) - if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) { - setSelectedNoteId(updated.id); - } - } - } catch (error) { - console.error('Update note failed:', error); + const persistNoteToServer = async (note: Note) => { + if (note.localOnly) { + const { title, body } = splitNoteContent(note.content); + const createdNote = await syncManager.createNote(title, body, note.category); + return { + ...createdNote, + content: note.content, + title, + favorite: note.favorite, + draftId: note.draftId, + localOnly: false, + }; } + + const remoteCategory = getRemoteCategory(note); + + if (remoteCategory !== note.category) { + const movedNote = await syncManager.moveNote(note, note.category); + return syncManager.updateNote({ + ...movedNote, + draftId: note.draftId, + title: note.title, + content: note.content, + favorite: note.favorite, + localOnly: false, + pendingSave: false, + isSaving: false, + saveError: null, + lastSavedAt: note.lastSavedAt, + }); + } + + return syncManager.updateNote(note); + }; + + const flushNoteSave = async (draftId: string, options: FlushSaveOptions = {}): Promise => { + const controller = ensureSaveController(draftId); + clearSaveTimer(draftId); + + const currentNote = getNoteByDraftId(draftId); + if (!currentNote?.pendingSave) { + return; + } + + const canPersist = options.force ? canForceSaveLocalNote(currentNote) : canAutosaveLocalNote(currentNote); + if (!canPersist) { + return; + } + + if (controller.inFlight) { + await controller.inFlight; + return; + } + + controller.inFlightRevision = controller.revision; + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId + ? { + ...note, + isSaving: true, + saveError: null, + } + : note + ) + ); + + const savePromise = (async () => { + try { + const noteToPersist = getNoteByDraftId(draftId); + if (!noteToPersist?.pendingSave) { + return; + } + + const canPersistLatest = options.force ? canForceSaveLocalNote(noteToPersist) : canAutosaveLocalNote(noteToPersist); + if (!canPersistLatest) { + return; + } + + const savedNote = { + ...(await persistNoteToServer(noteToPersist)), + draftId, + localOnly: false, + pendingSave: false, + isSaving: false, + saveError: null, + lastSavedAt: Date.now(), + }; + + if (noteToPersist.id !== savedNote.id) { + void localDB.deleteNote(noteToPersist.id).catch((error) => { + console.error('Failed to remove stale local note cache entry:', error); + }); + } + + savedSnapshotsRef.current.set(draftId, { + ...savedNote, + pendingSave: false, + isSaving: false, + saveError: null, + }); + + const latestNote = getNoteByDraftId(draftId); + const hasNewerChanges = controller.revision > controller.inFlightRevision && latestNote; + + if (latestNote && hasNewerChanges) { + const mergedPendingNote: Note = { + ...savedNote, + content: latestNote.content, + title: latestNote.title, + category: latestNote.category, + favorite: latestNote.favorite, + modified: latestNote.modified, + localOnly: false, + pendingSave: true, + isSaving: false, + saveError: null, + }; + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId ? mergedPendingNote : note + ) + ); + persistNoteToCache(mergedPendingNote); + scheduleNoteSave(draftId, 0); + return; + } + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId ? savedNote : note + ) + ); + persistNoteToCache(savedNote); + } catch (error) { + console.error('Update note failed:', error); + + const failedNote = getNoteByDraftId(draftId); + if (!failedNote) { + return; + } + + const erroredNote = { + ...failedNote, + pendingSave: true, + isSaving: false, + saveError: error instanceof Error ? error.message : 'Failed to save note.', + }; + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId ? erroredNote : note + ) + ); + persistNoteToCache(erroredNote); + } finally { + controller.inFlight = null; + } + })(); + + controller.inFlight = savePromise; + await savePromise; + }; + + const scheduleNoteSave = (draftId: string, delayMs = AUTOSAVE_DELAY_MS) => { + const controller = ensureSaveController(draftId); + clearSaveTimer(draftId); + + controller.timerId = window.setTimeout(() => { + controller.timerId = null; + void flushNoteSave(draftId); + }, delayMs); + }; + + const flushAllPendingSaves = async () => { + const pendingDraftIds = notesRef.current + .filter(note => note.pendingSave && note.draftId) + .map(note => note.draftId as string); + + await Promise.all(pendingDraftIds.map(draftId => flushNoteSave(draftId, { force: true }))); + }; + + const handleDraftChange = (updatedNote: Note) => { + const draftId = updatedNote.draftId; + if (!draftId) { + return; + } + + const localNote = { + ...updatedNote, + modified: Math.floor(Date.now() / 1000), + pendingSave: true, + isSaving: false, + saveError: null, + }; + + const controller = ensureSaveController(draftId); + controller.revision += 1; + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId ? localNote : note + ) + ); + persistNoteToCache(localNote); + scheduleNoteSave(draftId); + }; + + const handleManualSave = async (draftId: string) => { + await flushNoteSave(draftId, { force: true }); + }; + + const handleDiscardNote = (draftId: string) => { + const snapshot = savedSnapshotsRef.current.get(draftId); + if (!snapshot) { + return; + } + + clearSaveTimer(draftId); + const controller = ensureSaveController(draftId); + controller.revision += 1; + + const cleanSnapshot = { + ...snapshot, + pendingSave: false, + isSaving: false, + saveError: null, + }; + + setSortedNotes(previousNotes => + previousNotes.map(note => + note.draftId === draftId ? cleanSnapshot : note + ) + ); + persistNoteToCache(cleanSnapshot); + }; + + const handleSelectNote = async (draftId: string) => { + if (draftId === selectedNoteDraftId) { + return; + } + + if (selectedNoteDraftId) { + await flushNoteSave(selectedNoteDraftId, { force: true }); + } + + setSelectedNoteDraftId(draftId); }; const handleDeleteNote = async (note: Note) => { + const draftId = note.draftId; + if (!draftId) { + return; + } + try { - await syncManager.deleteNote(note); - const remainingNotes = notes.filter(n => n.id !== note.id); - setNotes(remainingNotes); - if (selectedNoteId === note.id) { - setSelectedNoteId(remainingNotes[0]?.id || null); + clearSaveTimer(draftId); + if (!note.localOnly) { + await syncManager.deleteNote(note); + } else { + await localDB.deleteNote(note.id); + } + + saveControllersRef.current.delete(draftId); + savedSnapshotsRef.current.delete(draftId); + + const remainingNotes = notesRef.current.filter(currentNote => currentNote.draftId !== draftId); + notesRef.current = sortNotes(remainingNotes); + setNotes(notesRef.current); + + if (selectedNoteDraftId === draftId) { + setSelectedNoteDraftId(getNoteDraftId(notesRef.current[0])); } } catch (error) { console.error('Delete note failed:', error); @@ -329,7 +796,8 @@ function MainApp() { return b.modified - a.modified; }); - const selectedNote = notes.find(n => n.id === selectedNoteId) || null; + const selectedNote = notes.find(n => n.draftId === selectedNoteDraftId) || null; + const hasUnsavedChanges = Boolean(selectedNote?.pendingSave); if (!isLoggedIn) { return ; @@ -362,8 +830,8 @@ function MainApp() { /> setIsFocusMode(!isFocusMode)} diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index ac42dd6..8c07218 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -1,6 +1,23 @@ import { Note, APIConfig } from '../types'; import { runtimeFetch } from '../services/runtimeFetch'; +type HttpStatusError = Error & { status?: number }; + +const createHttpStatusError = (message: string, status: number): HttpStatusError => { + const error = new Error(message) as HttpStatusError; + error.status = status; + return error; +}; + +const getHttpStatus = (error: unknown): number | null => { + if (typeof error !== 'object' || error === null || !('status' in error)) { + return null; + } + + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : null; +}; + export class NextcloudAPI { private baseURL: string; private serverURL: string; @@ -277,6 +294,72 @@ export class NextcloudAPI { return note.content; } + private buildNoteWebDAVPath(category: string, filename: string): string { + const categoryPath = category ? `/${category}` : ''; + return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; + } + + private async delay(ms: number): Promise { + await new Promise((resolve) => window.setTimeout(resolve, ms)); + } + + private async fetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number }> { + const webdavPath = this.buildNoteWebDAVPath(category, filename); + const response = await runtimeFetch(`${this.serverURL}${webdavPath}`, { + method: 'PROPFIND', + headers: { + 'Authorization': this.authHeader, + 'Depth': '0', + 'Content-Type': 'application/xml', + }, + body: ` + + + + + + `, + }); + + if (!response.ok) { + throw createHttpStatusError(`Failed to fetch note metadata: ${response.status}`, response.status); + } + + const xmlText = await response.text(); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); + const responseNode = xmlDoc.getElementsByTagNameNS('DAV:', 'response')[0]; + const propstat = responseNode?.getElementsByTagNameNS('DAV:', 'propstat')[0]; + const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0]; + const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || ''; + const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || ''; + const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : Math.floor(Date.now() / 1000); + + return { etag, modified }; + } + + private async tryFetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number } | null> { + try { + return await this.fetchNoteMetadataWebDAV(category, filename); + } catch (error) { + const status = getHttpStatus(error); + if (status === 404) { + return null; + } + + throw error; + } + } + + private async refreshNoteWebDAVMetadata(note: Note): Promise { + const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!); + return { + ...note, + etag: metadata.etag || note.etag, + modified: metadata.modified || note.modified, + }; + } + async fetchNotesWebDAV(): Promise { const webdavPath = `/remote.php/dav/files/${this.username}/Notes`; const url = `${this.serverURL}${webdavPath}`; @@ -392,7 +475,7 @@ export class NextcloudAPI { } } - const noteContent = `${title}\n${content}`; + const noteContent = content ? `${title}\n${content}` : title; const response = await runtimeFetch(url, { method: 'PUT', @@ -416,7 +499,7 @@ export class NextcloudAPI { path: category ? `${category}/${filename}` : filename, etag, readonly: false, - content, + content: noteContent, title, category, favorite: false, @@ -437,16 +520,37 @@ export class NextcloudAPI { // Rename the file first, then update content const renamedNote = await this.renameNoteWebDAV(note, newFilename); // Now update the content of the renamed file - return this.updateNoteContentWebDAV(renamedNote); + return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote)); } else { // Just update content - return this.updateNoteContentWebDAV(note); + return this.updateNoteContentWithRetryWebDAV(note); } } + private async updateNoteContentWithRetryWebDAV(note: Note, maxRetries = 2): Promise { + let currentNote = note; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await this.updateNoteContentWebDAV(currentNote); + } catch (error) { + const status = getHttpStatus(error); + const canRetry = status === 412 || status === 423; + + if (!canRetry || attempt === maxRetries) { + throw error; + } + + await this.delay(150 * (attempt + 1)); + currentNote = await this.refreshNoteWebDAVMetadata(currentNote); + } + } + + return this.updateNoteContentWebDAV(currentNote); + } + private async updateNoteContentWebDAV(note: Note): Promise { - const categoryPath = note.category ? `/${note.category}` : ''; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; + const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!); const url = `${this.serverURL}${webdavPath}`; const noteContent = this.formatNoteContent(note); @@ -463,9 +567,12 @@ export class NextcloudAPI { if (!response.ok && response.status !== 204) { if (response.status === 412) { - throw new Error('Note was modified by another client. Please refresh.'); + throw createHttpStatusError('Note was modified by another client. Please refresh.', response.status); } - throw new Error(`Failed to update note: ${response.status}`); + if (response.status === 423) { + throw createHttpStatusError('Note is temporarily locked. Retrying...', response.status); + } + throw createHttpStatusError(`Failed to update note: ${response.status}`, response.status); } const etag = response.headers.get('etag') || note.etag; @@ -478,23 +585,39 @@ export class NextcloudAPI { } private async renameNoteWebDAV(note: Note, newFilename: string): Promise { - const categoryPath = note.category ? `/${note.category}` : ''; - const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; - const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`; + const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!); + const newPath = this.buildNoteWebDAVPath(note.category, newFilename); const response = await runtimeFetch(`${this.serverURL}${oldPath}`, { method: 'MOVE', headers: { 'Authorization': this.authHeader, 'Destination': `${this.serverURL}${newPath}`, + 'If-Match': note.etag, }, }); if (!response.ok && response.status !== 201 && response.status !== 204) { - throw new Error(`Failed to rename note: ${response.status}`); + if (response.status === 404) { + const existingMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename); + if (existingMetadata) { + const newId = note.category ? `${note.category}/${newFilename}` : newFilename; + return { + ...note, + id: newId, + filename: newFilename, + path: note.category ? `${note.category}/${newFilename}` : newFilename, + etag: existingMetadata.etag || note.etag, + modified: existingMetadata.modified || Math.floor(Date.now() / 1000), + }; + } + } + + throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status); } // Also rename attachment folder if it exists + const categoryPath = note.category ? `/${note.category}` : ''; const oldNoteIdStr = String(note.id); const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); @@ -520,6 +643,7 @@ export class NextcloudAPI { // Attachment folder might not exist, that's ok } + const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename); const newId = note.category ? `${note.category}/${newFilename}` : newFilename; return { @@ -527,6 +651,8 @@ export class NextcloudAPI { id: newId, filename: newFilename, path: note.category ? `${note.category}/${newFilename}` : newFilename, + etag: refreshedMetadata?.etag || note.etag, + modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000), }; } @@ -540,8 +666,8 @@ export class NextcloudAPI { headers: { 'Authorization': this.authHeader }, }); - if (!response.ok && response.status !== 204) { - throw new Error(`Failed to delete note: ${response.status}`); + if (!response.ok && response.status !== 204 && response.status !== 404) { + throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status); } } diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 3f182f1..c76e9ad 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -18,9 +18,10 @@ import { interface NoteEditorProps { note: Note | null; - onUpdateNote: (note: Note) => void; + onChangeNote: (note: Note) => void; + onSaveNote: (draftId: string) => void | Promise; + onDiscardNote: (draftId: string) => void; onToggleFavorite?: (note: Note, favorite: boolean) => void; - onUnsavedChanges?: (hasChanges: boolean) => void; categories: string[]; isFocusMode?: boolean; onToggleFocusMode?: () => void; @@ -39,27 +40,24 @@ marked.use({ breaks: true, }); -export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { +export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onToggleFavorite, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { const [localContent, setLocalContent] = useState(''); const [localCategory, setLocalCategory] = useState(''); const [localFavorite, setLocalFavorite] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false); const [processedContent, setProcessedContent] = useState(''); const [isLoadingImages, setIsLoadingImages] = useState(false); const [isUploading, setIsUploading] = useState(false); - const previousNoteIdRef = useRef(null); - const previousNoteContentRef = useRef(''); + const previousDraftIdRef = useRef(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const desktopRuntime = getDesktopRuntime(); const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note'; - - useEffect(() => { - onUnsavedChanges?.(hasUnsavedChanges); - }, [hasUnsavedChanges, onUnsavedChanges]); + const hasUnsavedChanges = Boolean(note?.pendingSave); + const isSaving = Boolean(note?.isSaving); + const saveError = note?.saveError; + const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError; // Handle Escape key to exit focus mode useEffect(() => { @@ -103,8 +101,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan // Guard: Only process if localContent has been updated for the current note // This prevents processing stale content from the previous note - if (previousNoteIdRef.current !== note.id) { - console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`); + if (previousDraftIdRef.current !== note.draftId) { + console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`); return; } @@ -150,77 +148,56 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan }; processImages(); - }, [isPreviewMode, localContent, note?.id, api]); + }, [isPreviewMode, localContent, note?.draftId, note?.id, api]); useEffect(() => { - const loadNewNote = () => { - if (note) { - setLocalContent(note.content); - setLocalCategory(note.category || ''); - setLocalFavorite(note.favorite); - setHasUnsavedChanges(false); - - previousNoteIdRef.current = note.id; - previousNoteContentRef.current = note.content; - } - }; + if (!note) { + setLocalContent(''); + setLocalCategory(''); + setLocalFavorite(false); + previousDraftIdRef.current = null; + return; + } - // Switching to a different note - if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { - console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`); + if (previousDraftIdRef.current !== note.draftId) { setProcessedContent(''); - if (hasUnsavedChanges) { - handleSave(); - } - loadNewNote(); - } - // Same note but content changed from server (and no unsaved local changes) - else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) { - console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`); - loadNewNote(); + previousDraftIdRef.current = note.draftId ?? null; } - // Initial load - else if (!note || previousNoteIdRef.current === null) { - loadNewNote(); + + if (note.content !== localContent) { + setLocalContent(note.content); } - // Favorite status changed (e.g., from sync) - else if (note && note.favorite !== localFavorite) { + if ((note.category || '') !== localCategory) { + setLocalCategory(note.category || ''); + } + if (note.favorite !== localFavorite) { setLocalFavorite(note.favorite); } - }, [note?.id, note?.content, note?.modified, note?.favorite]); + }, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]); - const handleSave = () => { - if (!note || !hasUnsavedChanges) return; - - console.log('Saving note content length:', localContent.length); - console.log('Last 50 chars:', localContent.slice(-50)); - setIsSaving(true); - setHasUnsavedChanges(false); - - const title = getNoteTitleFromContent(localContent); - - onUpdateNote({ + const emitNoteChange = (content: string, category: string, favorite: boolean) => { + if (!note) { + return; + } + + onChangeNote({ ...note, - title, - content: localContent, - category: localCategory, - favorite: localFavorite, + title: getNoteTitleFromContent(content), + content, + category, + favorite, }); - setTimeout(() => setIsSaving(false), 500); }; const handleContentChange = (value: string) => { setLocalContent(value); - setHasUnsavedChanges(true); + emitNoteChange(value, localCategory, localFavorite); }; const handleDiscard = () => { - if (!note) return; - - setLocalContent(note.content); - setLocalCategory(note.category || ''); - setLocalFavorite(note.favorite); - setHasUnsavedChanges(false); + if (!note?.draftId) return; + + onDiscardNote(note.draftId); }; const handleExportPDF = async () => { @@ -295,25 +272,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan setLocalFavorite(newFavorite); if (note && onToggleFavorite) { - // Use dedicated favorite toggle callback if provided onToggleFavorite(note, newFavorite); - } else if (note) { - // Fallback to full update if no callback provided - const title = getNoteTitleFromContent(localContent); - - onUpdateNote({ - ...note, - title, - content: localContent, - category: localCategory, - favorite: newFavorite, - }); } }; const handleCategoryChange = (category: string) => { setLocalCategory(category); - setHasUnsavedChanges(true); + emitNoteChange(localContent, category, localFavorite); }; const handleAttachmentUpload = async (event: React.ChangeEvent) => { @@ -336,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan const cursorPos = textarea.selectionStart; const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); setLocalContent(newContent); - setHasUnsavedChanges(true); + emitNoteChange(newContent, localCategory, localFavorite); // Move cursor after inserted text setTimeout(() => { @@ -346,8 +311,9 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan }, 0); } else { // Append to end - setLocalContent(localContent + '\n' + markdownLink); - setHasUnsavedChanges(true); + const newContent = `${localContent}\n${markdownLink}`; + setLocalContent(newContent); + emitNoteChange(newContent, localCategory, localFavorite); } await showDesktopMessage('Attachment uploaded successfully!', { @@ -377,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan const markdownLink = `[${text}](${url})`; const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); setLocalContent(newContent); - setHasUnsavedChanges(true); + emitNoteChange(newContent, localCategory, localFavorite); setTimeout(() => { textarea.focus(); @@ -517,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end); setLocalContent(newContent); - setHasUnsavedChanges(true); + emitNoteChange(newContent, localCategory, localFavorite); setTimeout(() => { textarea.focus(); @@ -603,13 +569,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
{/* Status */} - {(hasUnsavedChanges || isSaving) && ( + {(hasUnsavedChanges || isSaving || saveError || hasSavedState) && ( - {isSaving ? 'Saving...' : 'Unsaved'} + {saveError ? 'Save failed' : isSaving ? 'Saving...' : hasUnsavedChanges ? 'Unsaved' : 'Saved'} )} @@ -631,8 +601,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan