Implement optimistic note updates with debounced autosave

- Replace immediate server saves with local-first optimistic updates
- Add 1-second debounced autosave with per-note save controllers
- Track note state with draftId instead of server-assigned id
- Implement save queue with in-flight request tracking and revision numbers
- Add pendingSave/isSaving/saveError flags to note state
- Store saved snapshots to detect content changes
- Flush pending saves on logout, category operations, and window
This commit is contained in:
drelich
2026-04-06 10:10:17 +02:00
parent e21e443a59
commit aba090a8ad
6 changed files with 835 additions and 214 deletions

View File

@@ -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<void> | 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<NextcloudAPI | null>(null);
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNoteId, setSelectedNoteId] = useState<number | string | null>(null);
const [selectedNoteDraftId, setSelectedNoteDraftId] = useState<string | null>(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<SyncStatus>('idle');
const [pendingSyncCount, setPendingSyncCount] = useState(0);
const isOnline = useOnlineStatus();
const notesRef = useRef<Note[]>([]);
const saveControllersRef = useRef<Map<string, SaveController>>(new Map());
const savedSnapshotsRef = useRef<Map<string, Note>>(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<string>();
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<void> => {
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 <LoginView onLogin={handleLogin} />;
@@ -362,8 +830,8 @@ function MainApp() {
/>
<NotesList
notes={filteredNotes}
selectedNoteId={selectedNoteId}
onSelectNote={setSelectedNoteId}
selectedNoteDraftId={selectedNoteDraftId}
onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
onSync={syncNotes}
@@ -380,9 +848,10 @@ function MainApp() {
)}
<NoteEditor
note={selectedNote}
onUpdateNote={handleUpdateNote}
onChangeNote={handleDraftChange}
onSaveNote={handleManualSave}
onDiscardNote={handleDiscardNote}
onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories}
isFocusMode={isFocusMode}
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}

View File

@@ -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<void> {
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: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</d:propfind>`,
});
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<Note> {
const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!);
return {
...note,
etag: metadata.etag || note.etag,
modified: metadata.modified || note.modified,
};
}
async fetchNotesWebDAV(): Promise<Note[]> {
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<Note> {
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<Note> {
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<Note> {
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);
}
}

View File

@@ -18,9 +18,10 @@ import {
interface NoteEditorProps {
note: Note | null;
onUpdateNote: (note: Note) => void;
onChangeNote: (note: Note) => void;
onSaveNote: (draftId: string) => void | Promise<void>;
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<number | string | null>(null);
const previousNoteContentRef = useRef<string>('');
const previousDraftIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
@@ -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
<div className="flex items-center gap-2">
{/* Status */}
{(hasUnsavedChanges || isSaving) && (
{(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
<span className={`text-xs px-2 py-1 rounded-full ${
isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
saveError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: hasUnsavedChanges
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
}`}>
{isSaving ? 'Saving...' : 'Unsaved'}
{saveError ? 'Save failed' : isSaving ? 'Saving...' : hasUnsavedChanges ? 'Unsaved' : 'Saved'}
</span>
)}
@@ -631,8 +601,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</button>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving}
onClick={() => {
if (note?.draftId) {
void onSaveNote(note.draftId);
}
}}
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'

View File

@@ -5,8 +5,8 @@ import { categoryColorsSync } from '../services/categoryColorsSync';
interface NotesListProps {
notes: Note[];
selectedNoteId: number | string | null;
onSelectNote: (id: number | string) => void;
selectedNoteDraftId: string | null;
onSelectNote: (draftId: string) => void | Promise<void>;
onCreateNote: () => void;
onDeleteNote: (note: Note) => void;
onSync: () => void;
@@ -22,7 +22,7 @@ interface NotesListProps {
export function NotesList({
notes,
selectedNoteId,
selectedNoteDraftId,
onSelectNote,
onCreateNote,
onDeleteNote,
@@ -93,11 +93,6 @@ export function NotesList({
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
e.stopPropagation();
// Prevent deletion if there are unsaved changes on a different note
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
}
if (deleteClickedId === note.id) {
// Second click - actually delete
onDeleteNote(note);
@@ -249,22 +244,18 @@ export function NotesList({
) : (
notes.map((note) => (
<div
key={note.id}
key={note.draftId ?? note.id}
onClick={() => {
// Prevent switching if current note has unsaved changes
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
if (note.draftId) {
void onSelectNote(note.draftId);
}
onSelectNote(note.id);
}}
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
note.id === selectedNoteId
note.draftId === selectedNoteDraftId
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
: hasUnsavedChanges
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={hasUnsavedChanges && note.id !== selectedNoteId ? 'Save current note before switching' : ''}
title={hasUnsavedChanges && note.draftId !== selectedNoteDraftId ? 'Saving current note before switching' : ''}
>
<div className="flex items-start justify-between mb-1">
<div className="flex items-center flex-1 min-w-0">

View File

@@ -4,6 +4,41 @@ import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
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 withLocalNoteFields = (note: Note, existing?: Note): Note => ({
...note,
draftId: note.draftId ?? existing?.draftId ?? createDraftId(),
localOnly: note.localOnly ?? existing?.localOnly ?? false,
pendingSave: note.pendingSave ?? existing?.pendingSave ?? false,
isSaving: false,
saveError: note.saveError ?? existing?.saveError ?? null,
lastSavedAt: note.lastSavedAt ?? existing?.lastSavedAt,
});
const toStoredNote = (note: Note): Note => ({
...note,
isSaving: false,
});
const getCachedNotes = async (): Promise<Note[]> => {
const rawNotes = await localDB.getAllNotes();
const normalizedNotes = rawNotes.map(note => withLocalNoteFields(note));
const needsNormalization = rawNotes.some((note) => !note.draftId || note.isSaving);
if (needsNormalization) {
await localDB.saveNotes(normalizedNotes.map(toStoredNote));
}
return normalizedNotes;
};
export class SyncManager {
private api: NextcloudAPI | null = null;
private isOnline: boolean = navigator.onLine;
@@ -46,7 +81,7 @@ export class SyncManager {
// Load notes: cache-first, then sync in background
async loadNotes(): Promise<Note[]> {
// Try to load from cache first (instant)
const cachedNotes = await localDB.getAllNotes();
const cachedNotes = await getCachedNotes();
// If we have cached notes and we're offline, return them
if (!this.isOnline) {
@@ -70,7 +105,7 @@ export class SyncManager {
this.notifyStatus('syncing', 0);
await this.fetchAndCacheNotes();
await this.syncFavoriteStatus();
const notes = await localDB.getAllNotes();
const notes = await getCachedNotes();
this.notifyStatus('idle', 0);
return notes;
} catch (error) {
@@ -89,7 +124,7 @@ export class SyncManager {
// Get metadata for all notes (fast - no content)
const serverNotes = await this.api.fetchNotesWebDAV();
const cachedNotes = await localDB.getAllNotes();
const cachedNotes = await getCachedNotes();
// Build maps for comparison
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
@@ -99,16 +134,23 @@ export class SyncManager {
const notesToFetch: Note[] = [];
for (const serverNote of serverNotes) {
const cached = cachedMap.get(serverNote.id);
if (cached?.pendingSave) {
continue;
}
if (!cached || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote);
notesToFetch.push(withLocalNoteFields(serverNote, cached));
}
}
// Fetch content for changed notes
for (const note of notesToFetch) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
const fullNote = withLocalNoteFields(
await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
await localDB.saveNote(toStoredNote(fullNote));
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
@@ -116,9 +158,13 @@ export class SyncManager {
// Remove deleted notes from cache (but protect recently modified notes)
for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
if (!serverMap.has(cachedNote.id)) {
// Don't delete notes that were recently created/updated (race condition protection)
if (!this.recentlyModifiedNotes.has(cachedNote.id)) {
if (!cachedNote.pendingSave && !this.recentlyModifiedNotes.has(cachedNote.id)) {
await localDB.deleteNote(cachedNote.id);
}
}
@@ -148,7 +194,7 @@ export class SyncManager {
try {
console.log('Syncing favorite status from API...');
const apiMetadata = await this.api.fetchNotesMetadata();
const cachedNotes = await localDB.getAllNotes();
const cachedNotes = await getCachedNotes();
// Map API notes by modified timestamp + category for reliable matching
// (titles can differ between API and WebDAV)
@@ -165,6 +211,10 @@ export class SyncManager {
// Update favorite status in cache for matching notes
for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
// Try timestamp match first (most reliable)
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
let apiData = apiByTimestamp.get(timestampKey);
@@ -178,7 +228,7 @@ export class SyncManager {
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);
await localDB.saveNote(toStoredNote(cachedNote));
}
}
@@ -193,14 +243,19 @@ export class SyncManager {
private async fetchAndCacheNotes(): Promise<Note[]> {
if (!this.api) throw new Error('API not initialized');
const cachedNotes = await getCachedNotes();
const cachedMap = new Map(cachedNotes.map(note => [note.id, note]));
const serverNotes = await this.api.fetchNotesWebDAV();
const notesWithContent: Note[] = [];
for (const note of serverNotes) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
const fullNote = withLocalNoteFields(
await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
notesWithContent.push(fullNote);
await localDB.saveNote(fullNote);
await localDB.saveNote(toStoredNote(fullNote));
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
@@ -220,8 +275,8 @@ export class SyncManager {
}
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
await localDB.saveNote(toStoredNote(fullNote));
return fullNote;
} catch (error) {
throw error;
@@ -241,8 +296,8 @@ export class SyncManager {
try {
this.notifyStatus('syncing', 0);
const note = await this.api.createNoteWebDAV(title, content, category);
await localDB.saveNote(note);
const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
await localDB.saveNote(toStoredNote(note));
// Protect this note from being deleted by background sync for a short window
this.protectNote(note.id);
@@ -268,7 +323,7 @@ export class SyncManager {
if (!this.isOnline) {
// Update locally, will sync when back online
note.favorite = favorite;
await localDB.saveNote(note);
await localDB.saveNote(toStoredNote(note));
return;
}
@@ -286,12 +341,12 @@ export class SyncManager {
// Update local cache
note.favorite = favorite;
await localDB.saveNote(note);
await localDB.saveNote(toStoredNote(note));
} catch (error) {
console.error('Failed to update favorite status:', error);
// Still update locally
note.favorite = favorite;
await localDB.saveNote(note);
await localDB.saveNote(toStoredNote(note));
}
}
@@ -309,14 +364,14 @@ export class SyncManager {
try {
this.notifyStatus('syncing', 0);
const oldId = note.id;
const updatedNote = await this.api.updateNoteWebDAV(note);
const updatedNote = withLocalNoteFields(await this.api.updateNoteWebDAV(note), note);
// If the note ID changed (due to filename change), delete the old cache entry
if (oldId !== updatedNote.id) {
await localDB.deleteNote(oldId);
}
await localDB.saveNote(updatedNote);
await localDB.saveNote(toStoredNote(updatedNote));
// Protect this note from being deleted by background sync for a short window
this.protectNote(updatedNote.id);
@@ -368,9 +423,9 @@ export class SyncManager {
try {
this.notifyStatus('syncing', 0);
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
const movedNote = withLocalNoteFields(await this.api.moveNoteWebDAV(note, newCategory), note);
await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote);
await localDB.saveNote(toStoredNote(movedNote));
// Protect the moved note from being deleted by background sync
this.protectNote(movedNote.id);

View File

@@ -9,6 +9,12 @@ export interface Note {
modified: number;
filename?: string; // WebDAV: actual filename on server
path?: string; // WebDAV: full path including category
draftId?: string; // stable client-side identity across renames/moves
localOnly?: boolean; // exists only in local cache until first successful server create
pendingSave?: boolean; // local-first dirty flag
isSaving?: boolean; // local transient UI state
saveError?: string | null; // last save error, if any
lastSavedAt?: number; // local timestamp for "Saved" feedback
}
export interface APIConfig {