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
This commit is contained in:
drelich
2026-03-26 09:14:42 +01:00
parent 36733da434
commit 244ba69eed
4 changed files with 143 additions and 4 deletions

View File

@@ -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() {
<NoteEditor
note={selectedNote}
onUpdateNote={handleUpdateNote}
onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories}
isFocusMode={isFocusMode}

View File

@@ -61,6 +61,51 @@ export class NextcloudAPI {
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
}
// Fetch lightweight note list with IDs and favorites for hybrid sync
async fetchNotesMetadata(): Promise<Array<{id: number, title: string, category: string, favorite: boolean, modified: number}>> {
const notes = await this.request<Note[]>('/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<void> {
await this.request<Note>(`/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<number | null> {
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<string> {
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
// The path from markdown is like: .attachments.38479/Screenshot.png

View File

@@ -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<string, string>();
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,
});
}
};

View File

@@ -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<void> {
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<string, {id: number, favorite: boolean}>();
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<Note[]> {
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<void> {
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<Note> {
if (!this.api) {