From 244ba69eed8d4fd25fa82e6a41e339589fbe42ad Mon Sep 17 00:00:00 2001 From: drelich Date: Thu, 26 Mar 2026 09:14:42 +0100 Subject: [PATCH] Implement hybrid WebDAV + API favorite sync - Keep WebDAV for reliable content sync - Add REST API calls for favorite status only - Match notes by modified timestamp + category (titles can differ) - Sync favorites from API after WebDAV sync completes - Update favorite via API when user toggles star - Tested and working with mobile app sync --- src/App.tsx | 13 ++++++ src/api/nextcloud.ts | 45 +++++++++++++++++++++ src/components/NoteEditor.tsx | 15 +++++-- src/services/syncManager.ts | 74 +++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c13ef10..8d60de2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,6 +198,18 @@ function App() { localStorage.setItem('previewFontSize', size.toString()); }; + const handleToggleFavorite = async (note: Note, favorite: boolean) => { + try { + await syncManager.updateFavoriteStatus(note, favorite); + // Update local state + setNotes(prevNotes => + prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n) + ); + } catch (error) { + console.error('Toggle favorite failed:', error); + } + }; + const handleCreateNote = async () => { try { const timestamp = new Date().toLocaleString('en-US', { @@ -355,6 +367,7 @@ function App() { (`/notes/${id}`, { method: 'DELETE' }); } + // Fetch lightweight note list with IDs and favorites for hybrid sync + async fetchNotesMetadata(): Promise> { + const notes = await this.request('/notes'); + return notes.map(note => ({ + id: note.id as number, + title: note.title, + category: note.category, + favorite: note.favorite, + modified: note.modified, + })); + } + + // Update only favorite status via API + async updateFavoriteStatus(noteId: number, favorite: boolean): Promise { + await this.request(`/notes/${noteId}`, { + method: 'PUT', + body: JSON.stringify({ favorite }), + }); + } + + // Map WebDAV note to API ID by matching modified timestamp and category + // We can't use title because API title and WebDAV first-line title can differ + async findApiIdForNote(title: string, category: string, modified: number): Promise { + try { + const metadata = await this.fetchNotesMetadata(); + + // First try exact title + category match + let match = metadata.find(note => + note.title === title && note.category === category + ); + + // If no title match, try modified timestamp + category (more reliable) + if (!match) { + match = metadata.find(note => + note.modified === modified && note.category === category + ); + } + + return match ? match.id : null; + } catch (error) { + console.error('Failed to find API ID for note:', error); + return null; + } + } + async fetchAttachment(_noteId: number | string, path: string, noteCategory?: string): Promise { // Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename} // The path from markdown is like: .attachments.38479/Screenshot.png diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index c931327..467910b 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -10,6 +10,7 @@ import { InsertToolbar } from './InsertToolbar'; interface NoteEditorProps { note: Note | null; onUpdateNote: (note: Note) => void; + onToggleFavorite?: (note: Note, favorite: boolean) => void; onUnsavedChanges?: (hasChanges: boolean) => void; categories: string[]; isFocusMode?: boolean; @@ -24,7 +25,7 @@ interface NoteEditorProps { const imageCache = new Map(); -export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { +export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, 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); @@ -399,8 +400,14 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i }; const handleFavoriteToggle = () => { - setLocalFavorite(!localFavorite); - if (note) { + const newFavorite = !localFavorite; + 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 firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); const title = firstLine || 'Untitled'; @@ -409,7 +416,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i title, content: localContent, category: localCategory, - favorite: !localFavorite, + favorite: newFavorite, }); } }; diff --git a/src/services/syncManager.ts b/src/services/syncManager.ts index 3212e74..bb042ba 100644 --- a/src/services/syncManager.ts +++ b/src/services/syncManager.ts @@ -117,6 +117,9 @@ export class SyncManager { } } + // Sync favorite status from API + await this.syncFavoriteStatus(); + this.notifyStatus('idle', 0); // Notify that sync is complete so UI can reload @@ -131,6 +134,41 @@ export class SyncManager { } } + // Sync favorite status from API to local cache + private async syncFavoriteStatus(): Promise { + if (!this.api) return; + + try { + console.log('Syncing favorite status from API...'); + const apiMetadata = await this.api.fetchNotesMetadata(); + const cachedNotes = await localDB.getAllNotes(); + + // Map API notes by title and category for matching + const apiMap = new Map(); + for (const apiNote of apiMetadata) { + const key = `${apiNote.category}/${apiNote.title}`; + apiMap.set(key, { id: apiNote.id, favorite: apiNote.favorite }); + } + + // Update favorite status in cache for matching notes + for (const cachedNote of cachedNotes) { + const key = `${cachedNote.category}/${cachedNote.title}`; + const apiData = apiMap.get(key); + + if (apiData && cachedNote.favorite !== apiData.favorite) { + console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`); + cachedNote.favorite = apiData.favorite; + await localDB.saveNote(cachedNote); + } + } + + console.log('Favorite status sync complete'); + } catch (error) { + console.error('Failed to sync favorite status:', error); + // Don't throw - favorite sync is non-critical + } + } + // Fetch all notes and cache them private async fetchAndCacheNotes(): Promise { if (!this.api) throw new Error('API not initialized'); @@ -197,6 +235,42 @@ export class SyncManager { } } + // Update favorite status via API + async updateFavoriteStatus(note: Note, favorite: boolean): Promise { + if (!this.api) { + throw new Error('API not initialized'); + } + + if (!this.isOnline) { + // Update locally, will sync when back online + note.favorite = favorite; + await localDB.saveNote(note); + return; + } + + try { + // Find API ID for this note + const apiId = await this.api.findApiIdForNote(note.title, note.category, note.modified); + + if (apiId) { + // Update via API + await this.api.updateFavoriteStatus(apiId, favorite); + console.log(`Updated favorite status for "${note.title}" (API ID: ${apiId})`); + } else { + console.warn(`Could not find API ID for note: "${note.title}"`); + } + + // Update local cache + note.favorite = favorite; + await localDB.saveNote(note); + } catch (error) { + console.error('Failed to update favorite status:', error); + // Still update locally + note.favorite = favorite; + await localDB.saveNote(note); + } + } + // Update note on server and cache async updateNote(note: Note): Promise { if (!this.api) {