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:
607
src/App.tsx
607
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<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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user