diff --git a/src/App.tsx b/src/App.tsx index 3c9cb48..c13ef10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,13 +73,6 @@ function App() { categoryColorsSync.setAPI(apiInstance); setUsername(savedUsername); setIsLoggedIn(true); - - // Load notes from local DB immediately - const localNotes = await localDB.getAllNotes(); - if (localNotes.length > 0) { - setNotes(localNotes.sort((a, b) => b.modified - a.modified)); - setSelectedNoteId(localNotes[0].id); - } } }; @@ -115,6 +108,13 @@ function App() { setSyncStatus(status); setPendingSyncCount(count); }); + + syncManager.setSyncCompleteCallback(async () => { + // 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)); + }); }, []); useEffect(() => { @@ -164,7 +164,6 @@ function App() { localStorage.removeItem('username'); localStorage.removeItem('password'); await localDB.clearNotes(); - await localDB.clearSyncQueue(); setApi(null); syncManager.setAPI(null); categoryColorsSync.setAPI(null); @@ -251,8 +250,27 @@ function App() { const handleUpdateNote = async (updatedNote: Note) => { try { - await syncManager.updateNote(updatedNote); - setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n)); + 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)); + } else { + setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n)); + } + } else { + const updated = await syncManager.updateNote(updatedNote); + setNotes(notes.map(n => n.id === updatedNote.id ? updated : n)); + } } catch (error) { console.error('Update note failed:', error); } @@ -260,7 +278,7 @@ function App() { const handleDeleteNote = async (note: Note) => { try { - await syncManager.deleteNote(note.id); + await syncManager.deleteNote(note); const remainingNotes = notes.filter(n => n.id !== note.id); setNotes(remainingNotes); if (selectedNoteId === note.id) { diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index f3b06d3..289b957 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -112,7 +112,14 @@ export class NextcloudAPI { webdavPath += `/${noteCategory}`; } - const attachmentDir = `.attachments.${noteId}`; + // Sanitize note ID: extract just the filename without extension and remove invalid chars + // noteId might be "category/filename.md" or just "filename.md" + const noteIdStr = String(noteId); + const justFilename = noteIdStr.split('/').pop() || noteIdStr; + const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, ''); + const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + + const attachmentDir = `.attachments.${sanitizedNoteId}`; const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; @@ -202,9 +209,9 @@ export class NextcloudAPI { // WebDAV-based note operations private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note { - const lines = content.split('\n'); - const title = lines[0] || filename.replace('.txt', ''); - const noteContent = lines.slice(1).join('\n').trim(); + // Extract title from first line + const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim(); + const title = firstLine || filename.replace(/\.(md|txt)$/, ''); return { id: `${category}/${filename}`, @@ -212,7 +219,7 @@ export class NextcloudAPI { path: category ? `${category}/${filename}` : filename, etag, readonly: false, - content: noteContent, + content, // Store full content including first line title, category, favorite: false, @@ -221,7 +228,8 @@ export class NextcloudAPI { } private formatNoteContent(note: Note): string { - return `${note.title}\n${note.content}`; + // Content already includes the title as first line + return note.content; } async fetchNotesWebDAV(): Promise { @@ -262,11 +270,11 @@ export class NextcloudAPI { const responseNode = responses[i]; const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || ''; - // Skip if not a .txt file - if (!href.endsWith('.txt')) continue; + // Skip if not a .md or .txt file + if (!href.endsWith('.md') && !href.endsWith('.txt')) continue; // Skip hidden files - const filename = href.split('/').pop() || ''; + const filename = decodeURIComponent(href.split('/').pop() || ''); if (filename.startsWith('.')) continue; const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0]; @@ -276,32 +284,52 @@ export class NextcloudAPI { const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || ''; const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0; - // Extract category from path + // Extract category from path and decode URL encoding const pathParts = href.split('/Notes/')[1]?.split('/'); - const category = pathParts && pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''; + const category = pathParts && pathParts.length > 1 + ? pathParts.slice(0, -1).map(part => decodeURIComponent(part)).join('/') + : ''; - // Fetch file content - try { - const fileUrl = `${this.serverURL}${href}`; - const fileResponse = await tauriFetch(fileUrl, { - headers: { 'Authorization': this.authHeader }, - }); - - if (fileResponse.ok) { - const content = await fileResponse.text(); - const note = this.parseNoteFromContent(content, filename, category, etag, modified); - notes.push(note); - } - } catch (error) { - console.error(`Failed to fetch note ${filename}:`, error); - } + // Create note with empty content - will be loaded on-demand + const title = filename.replace(/\.(md|txt)$/, ''); + const note: Note = { + id: category ? `${category}/${filename}` : filename, + filename, + path: category ? `${category}/${filename}` : filename, + etag, + readonly: false, + content: '', // Empty - load on demand + title, + category, + favorite: false, + modified, + }; + notes.push(note); } return notes; } + async fetchNoteContentWebDAV(note: Note): Promise { + const categoryPath = note.category ? `/${note.category}` : ''; + const filename = note.filename || String(note.id).split('/').pop() || 'note.md'; + const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`; + const url = `${this.serverURL}${webdavPath}`; + + const response = await tauriFetch(url, { + headers: { 'Authorization': this.authHeader }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch note content: ${response.status}`); + } + + const content = await response.text(); + return this.parseNoteFromContent(content, filename, note.category, note.etag, note.modified); + } + async createNoteWebDAV(title: string, content: string, category: string): Promise { - const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`; + const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`; const categoryPath = category ? `/${category}` : ''; const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`; const url = `${this.serverURL}${webdavPath}`; diff --git a/src/components/NotesList.tsx b/src/components/NotesList.tsx index 8e677e9..d201671 100644 --- a/src/components/NotesList.tsx +++ b/src/components/NotesList.tsx @@ -33,20 +33,25 @@ export function NotesList({ showFavoritesOnly, onToggleFavorites, hasUnsavedChanges, - syncStatus: _syncStatus, + syncStatus, pendingSyncCount, isOnline, }: NotesListProps) { - const [isSyncing, setIsSyncing] = React.useState(false); const [deleteClickedId, setDeleteClickedId] = React.useState(null); const [width, setWidth] = React.useState(() => { const saved = localStorage.getItem('notesListWidth'); - return saved ? parseInt(saved, 10) : 320; + return saved ? parseInt(saved) : 300; }); const [isResizing, setIsResizing] = React.useState(false); - const [, forceUpdate] = React.useReducer(x => x + 1, 0); const containerRef = React.useRef(null); + const isSyncing = syncStatus === 'syncing'; + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + + const handleSync = async () => { + await onSync(); + }; + // Listen for category color changes React.useEffect(() => { const handleCustomEvent = () => forceUpdate(); @@ -57,12 +62,6 @@ export function NotesList({ }; }, []); - const handleSync = async () => { - setIsSyncing(true); - await onSync(); - setTimeout(() => setIsSyncing(false), 500); - }; - React.useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; diff --git a/src/db/localDB.ts b/src/db/localDB.ts index ae952db..8eab2c1 100644 --- a/src/db/localDB.ts +++ b/src/db/localDB.ts @@ -1,7 +1,7 @@ import { Note } from '../types'; const DB_NAME = 'nextcloud-notes-db'; -const DB_VERSION = 1; +const DB_VERSION = 2; // Bumped to clear old cache with URL-encoded categories const NOTES_STORE = 'notes'; const SYNC_QUEUE_STORE = 'syncQueue'; @@ -29,11 +29,18 @@ class LocalDB { request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; + const oldVersion = event.oldVersion; if (!db.objectStoreNames.contains(NOTES_STORE)) { const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' }); notesStore.createIndex('modified', 'modified', { unique: false }); notesStore.createIndex('category', 'category', { unique: false }); + } else if (oldVersion < 2) { + // Clear notes store when upgrading to v2 to remove old cached notes + // with stripped first lines + const transaction = (event.target as IDBOpenDBRequest).transaction!; + const notesStore = transaction.objectStore(NOTES_STORE); + notesStore.clear(); } if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) { diff --git a/src/services/syncManager.ts b/src/services/syncManager.ts index e26d06a..3212e74 100644 --- a/src/services/syncManager.ts +++ b/src/services/syncManager.ts @@ -1,6 +1,6 @@ import { Note } from '../types'; import { NextcloudAPI } from '../api/nextcloud'; -import { localDB, SyncOperation } from '../db/localDB'; +import { localDB } from '../db/localDB'; export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; @@ -9,12 +9,12 @@ export class SyncManager { private isOnline: boolean = navigator.onLine; private syncInProgress: boolean = false; private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null; + private syncCompleteCallback: (() => void) | null = null; constructor() { window.addEventListener('online', () => { this.isOnline = true; this.notifyStatus('idle', 0); - this.processSyncQueue(); }); window.addEventListener('offline', () => { @@ -31,259 +31,253 @@ export class SyncManager { this.statusCallback = callback; } + setSyncCompleteCallback(callback: () => void) { + this.syncCompleteCallback = callback; + } + private notifyStatus(status: SyncStatus, pendingCount: number) { if (this.statusCallback) { this.statusCallback(status, pendingCount); } } - private async getPendingCount(): Promise { - const queue = await localDB.getSyncQueue(); - return queue.length; - } - - // Load notes from local DB first, then sync with server + // Load notes: cache-first, then sync in background async loadNotes(): Promise { - const localNotes = await localDB.getAllNotes(); + // Try to load from cache first (instant) + const cachedNotes = await localDB.getAllNotes(); - if (this.isOnline && this.api) { - try { - await this.syncWithServer(); - return await localDB.getAllNotes(); - } catch (error) { - console.error('Failed to sync with server, using local data:', error); - return localNotes; - } + // If we have cached notes and we're offline, return them + if (!this.isOnline) { + this.notifyStatus('offline', 0); + return cachedNotes; } - - return localNotes; - } - // Sync with server: fetch remote notes and merge with local - async syncWithServer(): Promise { - if (!this.api || !this.isOnline || this.syncInProgress) return; + // If we have cached notes, return them immediately + // Then sync in background + if (cachedNotes.length > 0) { + this.syncInBackground(); + return cachedNotes; + } - this.syncInProgress = true; - this.notifyStatus('syncing', await this.getPendingCount()); + // No cache - must fetch from server + if (!this.api) { + throw new Error('API not initialized'); + } try { - // First, process any pending operations - await this.processSyncQueue(); + this.notifyStatus('syncing', 0); + const notes = await this.fetchAndCacheNotes(); + this.notifyStatus('idle', 0); + return notes; + } catch (error) { + this.notifyStatus('error', 0); + throw error; + } + } - // Fetch notes directly from WebDAV (file system) - const serverNotes = await this.api.fetchNotesWebDAV(); - const localNotes = await localDB.getAllNotes(); + // Background sync: compare etags and only fetch changed content + private async syncInBackground(): Promise { + if (!this.api || this.syncInProgress) return; - // Merge strategy: use file path as unique identifier - const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes); + this.syncInProgress = true; + try { + this.notifyStatus('syncing', 0); - // Update local DB with merged notes (no clearNotes - safer!) - for (const note of mergedNotes) { - await localDB.saveNote(note); - } - - // Remove notes that no longer exist on server - const serverIds = new Set(serverNotes.map(n => n.id)); - for (const localNote of localNotes) { - if (!serverIds.has(localNote.id) && typeof localNote.id === 'string') { - await localDB.deleteNote(localNote.id); + // Get metadata for all notes (fast - no content) + const serverNotes = await this.api.fetchNotesWebDAV(); + const cachedNotes = await localDB.getAllNotes(); + + // Build maps for comparison + const serverMap = new Map(serverNotes.map(n => [n.id, n])); + const cachedMap = new Map(cachedNotes.map(n => [n.id, n])); + + // Find notes that need content fetched (new or changed etag) + const notesToFetch: Note[] = []; + for (const serverNote of serverNotes) { + const cached = cachedMap.get(serverNote.id); + if (!cached || cached.etag !== serverNote.etag) { + notesToFetch.push(serverNote); } } - + + // Fetch content for changed notes + for (const note of notesToFetch) { + try { + const fullNote = await this.api.fetchNoteContentWebDAV(note); + await localDB.saveNote(fullNote); + } catch (error) { + console.error(`Failed to fetch note ${note.id}:`, error); + } + } + + // Remove deleted notes from cache + for (const cachedNote of cachedNotes) { + if (!serverMap.has(cachedNote.id)) { + await localDB.deleteNote(cachedNote.id); + } + } + this.notifyStatus('idle', 0); + + // Notify that sync is complete so UI can reload + if (this.syncCompleteCallback) { + this.syncCompleteCallback(); + } } catch (error) { - console.error('Sync failed:', error); - this.notifyStatus('error', await this.getPendingCount()); - throw error; + console.error('Background sync failed:', error); + this.notifyStatus('error', 0); } finally { this.syncInProgress = false; } } - private mergeNotesWebDAV(localNotes: Note[], serverNotes: Note[]): Note[] { - const serverMap = new Map(serverNotes.map(n => [n.id, n])); - const localMap = new Map(localNotes.map(n => [n.id, n])); - const merged: Note[] = []; - - // Add all server notes (they are the source of truth for existing files) - serverNotes.forEach(serverNote => { - const localNote = localMap.get(serverNote.id); - - // If local version is newer, keep it (will be synced later) - if (localNote && localNote.modified > serverNote.modified) { - merged.push(localNote); - } else { - merged.push(serverNote); - } - }); - - // Add local-only notes (not yet synced to server) - localNotes.forEach(localNote => { - if (!serverMap.has(localNote.id)) { - merged.push(localNote); - } - }); - - return merged; - } - - // Create note (offline-first) - async createNote(title: string, content: string, category: string): Promise { - // Generate filename-based ID - const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`; - const tempNote: Note = { - id: `${category}/${filename}`, - filename, - path: category ? `${category}/${filename}` : filename, - etag: '', - readonly: false, - content, - title, - category, - favorite: false, - modified: Math.floor(Date.now() / 1000), - }; - - // Save to local DB immediately - await localDB.saveNote(tempNote); - - // Queue for sync - const operation: SyncOperation = { - id: `create-${tempNote.id}-${Date.now()}`, - type: 'create', - noteId: tempNote.id, - note: tempNote, - timestamp: Date.now(), - retryCount: 0, - }; - await localDB.addToSyncQueue(operation); - - // Try to sync immediately if online - if (this.isOnline && this.api) { - this.processSyncQueue().catch(console.error); - } else { - this.notifyStatus('offline', await this.getPendingCount()); - } - - return tempNote; - } - - // Update note (offline-first) - async updateNote(note: Note): Promise { - // Update local DB immediately - await localDB.saveNote(note); - - // Queue for sync - const operation: SyncOperation = { - id: `update-${note.id}-${Date.now()}`, - type: 'update', - noteId: note.id, - note, - timestamp: Date.now(), - retryCount: 0, - }; - await localDB.addToSyncQueue(operation); - - // Try to sync immediately if online - if (this.isOnline && this.api) { - this.processSyncQueue().catch(console.error); - } else { - this.notifyStatus('offline', await this.getPendingCount()); - } - - return note; - } - - // Delete note (offline-first) - async deleteNote(id: number | string): Promise { - // Delete from local DB immediately - await localDB.deleteNote(id); - - // Queue for sync - const operation: SyncOperation = { - id: `delete-${id}-${Date.now()}`, - type: 'delete', - noteId: id, - timestamp: Date.now(), - retryCount: 0, - }; - await localDB.addToSyncQueue(operation); - - // Try to sync immediately if online - if (this.isOnline && this.api) { - this.processSyncQueue().catch(console.error); - } else { - this.notifyStatus('offline', await this.getPendingCount()); - } - } - - // Process sync queue - async processSyncQueue(): Promise { - if (!this.api || !this.isOnline || this.syncInProgress) return; - - const queue = await localDB.getSyncQueue(); - if (queue.length === 0) return; - - this.syncInProgress = true; - this.notifyStatus('syncing', queue.length); - - for (const operation of queue) { - try { - await this.processOperation(operation); - await localDB.removeFromSyncQueue(operation.id); - } catch (error) { - console.error(`Failed to process operation ${operation.id}:`, error); - - // Increment retry count - operation.retryCount++; - if (operation.retryCount > 5) { - console.error(`Operation ${operation.id} failed after 5 retries, removing from queue`); - await localDB.removeFromSyncQueue(operation.id); - } else { - await localDB.addToSyncQueue(operation); - } - } - } - - this.syncInProgress = false; - const remainingCount = await this.getPendingCount(); - this.notifyStatus(remainingCount > 0 ? 'error' : 'idle', remainingCount); - } - - private async processOperation(operation: SyncOperation): Promise { + // Fetch all notes and cache them + private async fetchAndCacheNotes(): Promise { if (!this.api) throw new Error('API not initialized'); - - switch (operation.type) { - case 'create': - if (operation.note) { - const serverNote = await this.api.createNoteWebDAV( - operation.note.title, - operation.note.content, - operation.note.category - ); - - // Update local note with server response (etag, etc.) - await localDB.saveNote(serverNote); - } - break; - - case 'update': - if (operation.note) { - const serverNote = await this.api.updateNoteWebDAV(operation.note); - await localDB.saveNote(serverNote); - } - break; - - case 'delete': - if (operation.noteId) { - // For delete, we need the note object to know the filename - const note = await localDB.getNote(operation.noteId); - if (note) { - await this.api.deleteNoteWebDAV(note); - } - } - break; + + const serverNotes = await this.api.fetchNotesWebDAV(); + const notesWithContent: Note[] = []; + + for (const note of serverNotes) { + try { + const fullNote = await this.api.fetchNoteContentWebDAV(note); + notesWithContent.push(fullNote); + await localDB.saveNote(fullNote); + } catch (error) { + console.error(`Failed to fetch note ${note.id}:`, error); + } } + + return notesWithContent; + } + + // Fetch content for a specific note on-demand + async fetchNoteContent(note: Note): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + throw new Error('Cannot fetch note content while offline'); + } + + try { + const fullNote = await this.api.fetchNoteContentWebDAV(note); + await localDB.saveNote(fullNote); + return fullNote; + } catch (error) { + throw error; + } + } + + // Create note on server and cache + async createNote(title: string, content: string, category: string): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + this.notifyStatus('offline', 0); + throw new Error('Cannot create note while offline'); + } + + try { + this.notifyStatus('syncing', 0); + const note = await this.api.createNoteWebDAV(title, content, category); + await localDB.saveNote(note); + this.notifyStatus('idle', 0); + + // Trigger background sync to fetch any other changes + this.syncInBackground().catch(err => console.error('Background sync failed:', err)); + + return note; + } catch (error) { + this.notifyStatus('error', 0); + throw error; + } + } + + // Update note on server and cache + async updateNote(note: Note): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + this.notifyStatus('offline', 0); + throw new Error('Cannot update note while offline'); + } + + try { + this.notifyStatus('syncing', 0); + const updatedNote = await this.api.updateNoteWebDAV(note); + await localDB.saveNote(updatedNote); + this.notifyStatus('idle', 0); + + // Trigger background sync to fetch any other changes + this.syncInBackground().catch(err => console.error('Background sync failed:', err)); + + return updatedNote; + } catch (error) { + this.notifyStatus('error', 0); + throw error; + } + } + + // Delete note from server and cache + async deleteNote(note: Note): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + this.notifyStatus('offline', 0); + throw new Error('Cannot delete note while offline'); + } + + try { + this.notifyStatus('syncing', 0); + await this.api.deleteNoteWebDAV(note); + await localDB.deleteNote(note.id); + this.notifyStatus('idle', 0); + } catch (error) { + this.notifyStatus('error', 0); + throw error; + } + } + + // Move note to different category on server and cache + async moveNote(note: Note, newCategory: string): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + this.notifyStatus('offline', 0); + throw new Error('Cannot move note while offline'); + } + + try { + this.notifyStatus('syncing', 0); + const movedNote = await this.api.moveNoteWebDAV(note, newCategory); + await localDB.deleteNote(note.id); + await localDB.saveNote(movedNote); + this.notifyStatus('idle', 0); + + // Trigger background sync to fetch any other changes + this.syncInBackground().catch(err => console.error('Background sync failed:', err)); + + return movedNote; + } catch (error) { + this.notifyStatus('error', 0); + throw error; + } + } + + // Manual sync with server + async syncWithServer(): Promise { + if (!this.api || !this.isOnline || this.syncInProgress) return; + await this.syncInBackground(); } getOnlineStatus(): boolean {