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 { LoginView } from './components/LoginView';
|
||||||
import { NotesList } from './components/NotesList';
|
import { NotesList } from './components/NotesList';
|
||||||
import { NoteEditor } from './components/NoteEditor';
|
import { NoteEditor } from './components/NoteEditor';
|
||||||
@@ -16,11 +16,82 @@ const LazyPrintView = lazy(async () => {
|
|||||||
return { default: module.PrintView };
|
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() {
|
function MainApp() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
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 [searchText, setSearchText] = useState('');
|
||||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('');
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
@@ -30,7 +101,6 @@ function MainApp() {
|
|||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
||||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||||
@@ -38,6 +108,89 @@ function MainApp() {
|
|||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
const isOnline = useOnlineStatus();
|
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(() => {
|
useEffect(() => {
|
||||||
const initApp = async () => {
|
const initApp = async () => {
|
||||||
@@ -119,7 +272,7 @@ function MainApp() {
|
|||||||
// Reload notes from cache after background sync completes
|
// Reload notes from cache after background sync completes
|
||||||
// Don't call loadNotes() as it triggers another sync - just reload from cache
|
// Don't call loadNotes() as it triggers another sync - just reload from cache
|
||||||
const cachedNotes = await localDB.getAllNotes();
|
const cachedNotes = await localDB.getAllNotes();
|
||||||
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified));
|
applyLoadedNotes(cachedNotes);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -131,13 +284,43 @@ function MainApp() {
|
|||||||
}
|
}
|
||||||
}, [api, isLoggedIn]);
|
}, [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 () => {
|
const loadNotes = async () => {
|
||||||
try {
|
try {
|
||||||
const loadedNotes = await syncManager.loadNotes();
|
const loadedNotes = await syncManager.loadNotes();
|
||||||
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
|
applyLoadedNotes(loadedNotes);
|
||||||
if (!selectedNoteId && loadedNotes.length > 0) {
|
|
||||||
setSelectedNoteId(loadedNotes[0].id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load notes:', error);
|
console.error('Failed to load notes:', error);
|
||||||
}
|
}
|
||||||
@@ -145,6 +328,7 @@ function MainApp() {
|
|||||||
|
|
||||||
const syncNotes = async () => {
|
const syncNotes = async () => {
|
||||||
try {
|
try {
|
||||||
|
await flushAllPendingSaves();
|
||||||
await syncManager.syncWithServer();
|
await syncManager.syncWithServer();
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,8 +359,11 @@ function MainApp() {
|
|||||||
categoryColorsSync.setAPI(null);
|
categoryColorsSync.setAPI(null);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
setSelectedNoteId(null);
|
notesRef.current = [];
|
||||||
|
setSelectedNoteDraftId(null);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
|
saveControllersRef.current.clear();
|
||||||
|
savedSnapshotsRef.current.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||||
@@ -205,25 +392,66 @@ function MainApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleFavorite = async (note: Note, favorite: boolean) => {
|
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 {
|
try {
|
||||||
await syncManager.updateFavoriteStatus(note, favorite);
|
await syncManager.updateFavoriteStatus(optimisticNote, favorite);
|
||||||
// Update local state
|
const snapshot = savedSnapshotsRef.current.get(draftId);
|
||||||
setNotes(prevNotes =>
|
if (snapshot) {
|
||||||
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
|
savedSnapshotsRef.current.set(draftId, {
|
||||||
);
|
...snapshot,
|
||||||
|
favorite,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Toggle favorite failed:', error);
|
console.error('Toggle favorite failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleCreateNote = async () => {
|
||||||
try {
|
const draftId = createDraftId();
|
||||||
const note = await syncManager.createNote('New Note', '', selectedCategory);
|
const note: Note = {
|
||||||
setNotes([note, ...notes]);
|
id: `local:${draftId}`,
|
||||||
setSelectedNoteId(note.id);
|
etag: '',
|
||||||
} catch (error) {
|
readonly: false,
|
||||||
console.error('Create note failed:', error);
|
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) => {
|
const handleCreateCategory = (name: string) => {
|
||||||
@@ -239,7 +467,19 @@ function MainApp() {
|
|||||||
for (const note of notesToMove) {
|
for (const note of notesToMove) {
|
||||||
try {
|
try {
|
||||||
const movedNote = await syncManager.moveNote(note, newName);
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to move note ${note.id}:`, error);
|
console.error(`Failed to move note ${note.id}:`, error);
|
||||||
}
|
}
|
||||||
@@ -256,53 +496,280 @@ function MainApp() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateNote = async (updatedNote: Note) => {
|
const persistNoteToServer = async (note: Note) => {
|
||||||
try {
|
if (note.localOnly) {
|
||||||
const originalNote = notes.find(n => n.id === updatedNote.id);
|
const { title, body } = splitNoteContent(note.content);
|
||||||
|
const createdNote = await syncManager.createNote(title, body, note.category);
|
||||||
// If category changed, use moveNote instead of updateNote
|
return {
|
||||||
if (originalNote && originalNote.category !== updatedNote.category) {
|
...createdNote,
|
||||||
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
|
content: note.content,
|
||||||
// If content/title also changed, update the moved note
|
title,
|
||||||
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) {
|
favorite: note.favorite,
|
||||||
const finalNote = await syncManager.updateNote({
|
draftId: note.draftId,
|
||||||
...movedNote,
|
localOnly: false,
|
||||||
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 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 handleDeleteNote = async (note: Note) => {
|
||||||
|
const draftId = note.draftId;
|
||||||
|
if (!draftId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await syncManager.deleteNote(note);
|
clearSaveTimer(draftId);
|
||||||
const remainingNotes = notes.filter(n => n.id !== note.id);
|
if (!note.localOnly) {
|
||||||
setNotes(remainingNotes);
|
await syncManager.deleteNote(note);
|
||||||
if (selectedNoteId === note.id) {
|
} else {
|
||||||
setSelectedNoteId(remainingNotes[0]?.id || null);
|
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) {
|
} catch (error) {
|
||||||
console.error('Delete note failed:', error);
|
console.error('Delete note failed:', error);
|
||||||
@@ -329,7 +796,8 @@ function MainApp() {
|
|||||||
return b.modified - a.modified;
|
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) {
|
if (!isLoggedIn) {
|
||||||
return <LoginView onLogin={handleLogin} />;
|
return <LoginView onLogin={handleLogin} />;
|
||||||
@@ -362,8 +830,8 @@ function MainApp() {
|
|||||||
/>
|
/>
|
||||||
<NotesList
|
<NotesList
|
||||||
notes={filteredNotes}
|
notes={filteredNotes}
|
||||||
selectedNoteId={selectedNoteId}
|
selectedNoteDraftId={selectedNoteDraftId}
|
||||||
onSelectNote={setSelectedNoteId}
|
onSelectNote={handleSelectNote}
|
||||||
onCreateNote={handleCreateNote}
|
onCreateNote={handleCreateNote}
|
||||||
onDeleteNote={handleDeleteNote}
|
onDeleteNote={handleDeleteNote}
|
||||||
onSync={syncNotes}
|
onSync={syncNotes}
|
||||||
@@ -380,9 +848,10 @@ function MainApp() {
|
|||||||
)}
|
)}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selectedNote}
|
note={selectedNote}
|
||||||
onUpdateNote={handleUpdateNote}
|
onChangeNote={handleDraftChange}
|
||||||
|
onSaveNote={handleManualSave}
|
||||||
|
onDiscardNote={handleDiscardNote}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onUnsavedChanges={setHasUnsavedChanges}
|
|
||||||
categories={categories}
|
categories={categories}
|
||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { Note, APIConfig } from '../types';
|
import { Note, APIConfig } from '../types';
|
||||||
import { runtimeFetch } from '../services/runtimeFetch';
|
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 {
|
export class NextcloudAPI {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
private serverURL: string;
|
private serverURL: string;
|
||||||
@@ -277,6 +294,72 @@ export class NextcloudAPI {
|
|||||||
return note.content;
|
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[]> {
|
async fetchNotesWebDAV(): Promise<Note[]> {
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
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, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -416,7 +499,7 @@ export class NextcloudAPI {
|
|||||||
path: category ? `${category}/${filename}` : filename,
|
path: category ? `${category}/${filename}` : filename,
|
||||||
etag,
|
etag,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
content,
|
content: noteContent,
|
||||||
title,
|
title,
|
||||||
category,
|
category,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
@@ -437,16 +520,37 @@ export class NextcloudAPI {
|
|||||||
// Rename the file first, then update content
|
// Rename the file first, then update content
|
||||||
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
|
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
|
||||||
// Now update the content of the renamed file
|
// Now update the content of the renamed file
|
||||||
return this.updateNoteContentWebDAV(renamedNote);
|
return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
|
||||||
} else {
|
} else {
|
||||||
// Just update content
|
// 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> {
|
private async updateNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const noteContent = this.formatNoteContent(note);
|
const noteContent = this.formatNoteContent(note);
|
||||||
@@ -463,9 +567,12 @@ export class NextcloudAPI {
|
|||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
if (!response.ok && response.status !== 204) {
|
||||||
if (response.status === 412) {
|
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;
|
const etag = response.headers.get('etag') || note.etag;
|
||||||
@@ -478,23 +585,39 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
|
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
|
||||||
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
|
|
||||||
|
|
||||||
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
'Destination': `${this.serverURL}${newPath}`,
|
'Destination': `${this.serverURL}${newPath}`,
|
||||||
|
'If-Match': note.etag,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
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
|
// Also rename attachment folder if it exists
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
const oldNoteIdStr = String(note.id);
|
const oldNoteIdStr = String(note.id);
|
||||||
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
|
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
|
||||||
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
|
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
|
||||||
@@ -520,6 +643,7 @@ export class NextcloudAPI {
|
|||||||
// Attachment folder might not exist, that's ok
|
// Attachment folder might not exist, that's ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
|
||||||
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -527,6 +651,8 @@ export class NextcloudAPI {
|
|||||||
id: newId,
|
id: newId,
|
||||||
filename: newFilename,
|
filename: newFilename,
|
||||||
path: note.category ? `${note.category}/${newFilename}` : 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 },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
if (!response.ok && response.status !== 204 && response.status !== 404) {
|
||||||
throw new Error(`Failed to delete note: ${response.status}`);
|
throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note | null;
|
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;
|
onToggleFavorite?: (note: Note, favorite: boolean) => void;
|
||||||
onUnsavedChanges?: (hasChanges: boolean) => void;
|
|
||||||
categories: string[];
|
categories: string[];
|
||||||
isFocusMode?: boolean;
|
isFocusMode?: boolean;
|
||||||
onToggleFocusMode?: () => void;
|
onToggleFocusMode?: () => void;
|
||||||
@@ -39,27 +40,24 @@ marked.use({
|
|||||||
breaks: true,
|
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 [localContent, setLocalContent] = useState('');
|
||||||
const [localCategory, setLocalCategory] = useState('');
|
const [localCategory, setLocalCategory] = useState('');
|
||||||
const [localFavorite, setLocalFavorite] = useState(false);
|
const [localFavorite, setLocalFavorite] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||||
const [processedContent, setProcessedContent] = useState('');
|
const [processedContent, setProcessedContent] = useState('');
|
||||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const previousNoteIdRef = useRef<number | string | null>(null);
|
const previousDraftIdRef = useRef<string | null>(null);
|
||||||
const previousNoteContentRef = useRef<string>('');
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const desktopRuntime = getDesktopRuntime();
|
const desktopRuntime = getDesktopRuntime();
|
||||||
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
|
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
|
||||||
|
const hasUnsavedChanges = Boolean(note?.pendingSave);
|
||||||
useEffect(() => {
|
const isSaving = Boolean(note?.isSaving);
|
||||||
onUnsavedChanges?.(hasUnsavedChanges);
|
const saveError = note?.saveError;
|
||||||
}, [hasUnsavedChanges, onUnsavedChanges]);
|
const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError;
|
||||||
|
|
||||||
// Handle Escape key to exit focus mode
|
// Handle Escape key to exit focus mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,8 +101,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
|
|
||||||
// Guard: Only process if localContent has been updated for the current note
|
// Guard: Only process if localContent has been updated for the current note
|
||||||
// This prevents processing stale content from the previous note
|
// This prevents processing stale content from the previous note
|
||||||
if (previousNoteIdRef.current !== note.id) {
|
if (previousDraftIdRef.current !== note.draftId) {
|
||||||
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`);
|
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,77 +148,56 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
};
|
};
|
||||||
|
|
||||||
processImages();
|
processImages();
|
||||||
}, [isPreviewMode, localContent, note?.id, api]);
|
}, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadNewNote = () => {
|
if (!note) {
|
||||||
if (note) {
|
setLocalContent('');
|
||||||
setLocalContent(note.content);
|
setLocalCategory('');
|
||||||
setLocalCategory(note.category || '');
|
setLocalFavorite(false);
|
||||||
setLocalFavorite(note.favorite);
|
previousDraftIdRef.current = null;
|
||||||
setHasUnsavedChanges(false);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
previousNoteIdRef.current = note.id;
|
if (previousDraftIdRef.current !== note.draftId) {
|
||||||
previousNoteContentRef.current = note.content;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Switching to a different note
|
|
||||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
|
||||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
|
||||||
setProcessedContent('');
|
setProcessedContent('');
|
||||||
if (hasUnsavedChanges) {
|
previousDraftIdRef.current = note.draftId ?? null;
|
||||||
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) {
|
if (note.content !== localContent) {
|
||||||
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
|
setLocalContent(note.content);
|
||||||
loadNewNote();
|
|
||||||
}
|
}
|
||||||
// Initial load
|
if ((note.category || '') !== localCategory) {
|
||||||
else if (!note || previousNoteIdRef.current === null) {
|
setLocalCategory(note.category || '');
|
||||||
loadNewNote();
|
|
||||||
}
|
}
|
||||||
// Favorite status changed (e.g., from sync)
|
if (note.favorite !== localFavorite) {
|
||||||
else if (note && note.favorite !== localFavorite) {
|
|
||||||
setLocalFavorite(note.favorite);
|
setLocalFavorite(note.favorite);
|
||||||
}
|
}
|
||||||
}, [note?.id, note?.content, note?.modified, note?.favorite]);
|
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
|
||||||
if (!note || !hasUnsavedChanges) return;
|
if (!note) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Saving note content length:', localContent.length);
|
onChangeNote({
|
||||||
console.log('Last 50 chars:', localContent.slice(-50));
|
|
||||||
setIsSaving(true);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
const title = getNoteTitleFromContent(localContent);
|
|
||||||
|
|
||||||
onUpdateNote({
|
|
||||||
...note,
|
...note,
|
||||||
title,
|
title: getNoteTitleFromContent(content),
|
||||||
content: localContent,
|
content,
|
||||||
category: localCategory,
|
category,
|
||||||
favorite: localFavorite,
|
favorite,
|
||||||
});
|
});
|
||||||
setTimeout(() => setIsSaving(false), 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContentChange = (value: string) => {
|
const handleContentChange = (value: string) => {
|
||||||
setLocalContent(value);
|
setLocalContent(value);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(value, localCategory, localFavorite);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
if (!note) return;
|
if (!note?.draftId) return;
|
||||||
|
|
||||||
setLocalContent(note.content);
|
onDiscardNote(note.draftId);
|
||||||
setLocalCategory(note.category || '');
|
|
||||||
setLocalFavorite(note.favorite);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPDF = async () => {
|
const handleExportPDF = async () => {
|
||||||
@@ -295,25 +272,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
setLocalFavorite(newFavorite);
|
setLocalFavorite(newFavorite);
|
||||||
|
|
||||||
if (note && onToggleFavorite) {
|
if (note && onToggleFavorite) {
|
||||||
// Use dedicated favorite toggle callback if provided
|
|
||||||
onToggleFavorite(note, newFavorite);
|
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) => {
|
const handleCategoryChange = (category: string) => {
|
||||||
setLocalCategory(category);
|
setLocalCategory(category);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(localContent, category, localFavorite);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -336,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
const cursorPos = textarea.selectionStart;
|
const cursorPos = textarea.selectionStart;
|
||||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
// Move cursor after inserted text
|
// Move cursor after inserted text
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -346,8 +311,9 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
// Append to end
|
// Append to end
|
||||||
setLocalContent(localContent + '\n' + markdownLink);
|
const newContent = `${localContent}\n${markdownLink}`;
|
||||||
setHasUnsavedChanges(true);
|
setLocalContent(newContent);
|
||||||
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
await showDesktopMessage('Attachment uploaded successfully!', {
|
await showDesktopMessage('Attachment uploaded successfully!', {
|
||||||
@@ -377,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
const markdownLink = `[${text}](${url})`;
|
const markdownLink = `[${text}](${url})`;
|
||||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
@@ -517,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
|
|
||||||
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
|
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
@@ -603,13 +569,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
{(hasUnsavedChanges || isSaving) && (
|
{(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
|
||||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||||
isSaving
|
saveError
|
||||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||||
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -631,8 +601,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={() => {
|
||||||
disabled={!hasUnsavedChanges || isSaving}
|
if (note?.draftId) {
|
||||||
|
void onSaveNote(note.draftId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
hasUnsavedChanges && !isSaving
|
hasUnsavedChanges && !isSaving
|
||||||
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
|
? '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 {
|
interface NotesListProps {
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
selectedNoteId: number | string | null;
|
selectedNoteDraftId: string | null;
|
||||||
onSelectNote: (id: number | string) => void;
|
onSelectNote: (draftId: string) => void | Promise<void>;
|
||||||
onCreateNote: () => void;
|
onCreateNote: () => void;
|
||||||
onDeleteNote: (note: Note) => void;
|
onDeleteNote: (note: Note) => void;
|
||||||
onSync: () => void;
|
onSync: () => void;
|
||||||
@@ -22,7 +22,7 @@ interface NotesListProps {
|
|||||||
|
|
||||||
export function NotesList({
|
export function NotesList({
|
||||||
notes,
|
notes,
|
||||||
selectedNoteId,
|
selectedNoteDraftId,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
onDeleteNote,
|
onDeleteNote,
|
||||||
@@ -93,11 +93,6 @@ export function NotesList({
|
|||||||
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Prevent deletion if there are unsaved changes on a different note
|
|
||||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteClickedId === note.id) {
|
if (deleteClickedId === note.id) {
|
||||||
// Second click - actually delete
|
// Second click - actually delete
|
||||||
onDeleteNote(note);
|
onDeleteNote(note);
|
||||||
@@ -249,22 +244,18 @@ export function NotesList({
|
|||||||
) : (
|
) : (
|
||||||
notes.map((note) => (
|
notes.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.draftId ?? note.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Prevent switching if current note has unsaved changes
|
if (note.draftId) {
|
||||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
void onSelectNote(note.draftId);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
onSelectNote(note.id);
|
|
||||||
}}
|
}}
|
||||||
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
|
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'
|
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
|
||||||
: hasUnsavedChanges
|
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
? 'cursor-not-allowed opacity-50'
|
|
||||||
: '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-start justify-between mb-1">
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<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';
|
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 {
|
export class SyncManager {
|
||||||
private api: NextcloudAPI | null = null;
|
private api: NextcloudAPI | null = null;
|
||||||
private isOnline: boolean = navigator.onLine;
|
private isOnline: boolean = navigator.onLine;
|
||||||
@@ -46,7 +81,7 @@ export class SyncManager {
|
|||||||
// Load notes: cache-first, then sync in background
|
// Load notes: cache-first, then sync in background
|
||||||
async loadNotes(): Promise<Note[]> {
|
async loadNotes(): Promise<Note[]> {
|
||||||
// Try to load from cache first (instant)
|
// 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 we have cached notes and we're offline, return them
|
||||||
if (!this.isOnline) {
|
if (!this.isOnline) {
|
||||||
@@ -70,7 +105,7 @@ export class SyncManager {
|
|||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
await this.fetchAndCacheNotes();
|
await this.fetchAndCacheNotes();
|
||||||
await this.syncFavoriteStatus();
|
await this.syncFavoriteStatus();
|
||||||
const notes = await localDB.getAllNotes();
|
const notes = await getCachedNotes();
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
return notes;
|
return notes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,7 +124,7 @@ export class SyncManager {
|
|||||||
|
|
||||||
// Get metadata for all notes (fast - no content)
|
// Get metadata for all notes (fast - no content)
|
||||||
const serverNotes = await this.api.fetchNotesWebDAV();
|
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
const cachedNotes = await localDB.getAllNotes();
|
const cachedNotes = await getCachedNotes();
|
||||||
|
|
||||||
// Build maps for comparison
|
// Build maps for comparison
|
||||||
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
||||||
@@ -99,16 +134,23 @@ export class SyncManager {
|
|||||||
const notesToFetch: Note[] = [];
|
const notesToFetch: Note[] = [];
|
||||||
for (const serverNote of serverNotes) {
|
for (const serverNote of serverNotes) {
|
||||||
const cached = cachedMap.get(serverNote.id);
|
const cached = cachedMap.get(serverNote.id);
|
||||||
|
if (cached?.pendingSave) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!cached || cached.etag !== serverNote.etag) {
|
if (!cached || cached.etag !== serverNote.etag) {
|
||||||
notesToFetch.push(serverNote);
|
notesToFetch.push(withLocalNoteFields(serverNote, cached));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch content for changed notes
|
// Fetch content for changed notes
|
||||||
for (const note of notesToFetch) {
|
for (const note of notesToFetch) {
|
||||||
try {
|
try {
|
||||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
const fullNote = withLocalNoteFields(
|
||||||
await localDB.saveNote(fullNote);
|
await this.api.fetchNoteContentWebDAV(note),
|
||||||
|
cachedMap.get(note.id)
|
||||||
|
);
|
||||||
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch note ${note.id}:`, 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)
|
// Remove deleted notes from cache (but protect recently modified notes)
|
||||||
for (const cachedNote of cachedNotes) {
|
for (const cachedNote of cachedNotes) {
|
||||||
|
if (cachedNote.localOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!serverMap.has(cachedNote.id)) {
|
if (!serverMap.has(cachedNote.id)) {
|
||||||
// Don't delete notes that were recently created/updated (race condition protection)
|
// 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);
|
await localDB.deleteNote(cachedNote.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +194,7 @@ export class SyncManager {
|
|||||||
try {
|
try {
|
||||||
console.log('Syncing favorite status from API...');
|
console.log('Syncing favorite status from API...');
|
||||||
const apiMetadata = await this.api.fetchNotesMetadata();
|
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
|
// Map API notes by modified timestamp + category for reliable matching
|
||||||
// (titles can differ between API and WebDAV)
|
// (titles can differ between API and WebDAV)
|
||||||
@@ -165,6 +211,10 @@ export class SyncManager {
|
|||||||
|
|
||||||
// Update favorite status in cache for matching notes
|
// Update favorite status in cache for matching notes
|
||||||
for (const cachedNote of cachedNotes) {
|
for (const cachedNote of cachedNotes) {
|
||||||
|
if (cachedNote.localOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Try timestamp match first (most reliable)
|
// Try timestamp match first (most reliable)
|
||||||
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
|
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
|
||||||
let apiData = apiByTimestamp.get(timestampKey);
|
let apiData = apiByTimestamp.get(timestampKey);
|
||||||
@@ -178,7 +228,7 @@ export class SyncManager {
|
|||||||
if (apiData && cachedNote.favorite !== apiData.favorite) {
|
if (apiData && cachedNote.favorite !== apiData.favorite) {
|
||||||
console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`);
|
console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`);
|
||||||
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[]> {
|
private async fetchAndCacheNotes(): Promise<Note[]> {
|
||||||
if (!this.api) throw new Error('API not initialized');
|
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 serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
const notesWithContent: Note[] = [];
|
const notesWithContent: Note[] = [];
|
||||||
|
|
||||||
for (const note of serverNotes) {
|
for (const note of serverNotes) {
|
||||||
try {
|
try {
|
||||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
const fullNote = withLocalNoteFields(
|
||||||
|
await this.api.fetchNoteContentWebDAV(note),
|
||||||
|
cachedMap.get(note.id)
|
||||||
|
);
|
||||||
notesWithContent.push(fullNote);
|
notesWithContent.push(fullNote);
|
||||||
await localDB.saveNote(fullNote);
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch note ${note.id}:`, error);
|
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||||
}
|
}
|
||||||
@@ -220,8 +275,8 @@ export class SyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
|
||||||
await localDB.saveNote(fullNote);
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
return fullNote;
|
return fullNote;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -241,8 +296,8 @@ export class SyncManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
const note = await this.api.createNoteWebDAV(title, content, category);
|
const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
|
||||||
await localDB.saveNote(note);
|
await localDB.saveNote(toStoredNote(note));
|
||||||
|
|
||||||
// Protect this note from being deleted by background sync for a short window
|
// Protect this note from being deleted by background sync for a short window
|
||||||
this.protectNote(note.id);
|
this.protectNote(note.id);
|
||||||
@@ -268,7 +323,7 @@ export class SyncManager {
|
|||||||
if (!this.isOnline) {
|
if (!this.isOnline) {
|
||||||
// Update locally, will sync when back online
|
// Update locally, will sync when back online
|
||||||
note.favorite = favorite;
|
note.favorite = favorite;
|
||||||
await localDB.saveNote(note);
|
await localDB.saveNote(toStoredNote(note));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,12 +341,12 @@ export class SyncManager {
|
|||||||
|
|
||||||
// Update local cache
|
// Update local cache
|
||||||
note.favorite = favorite;
|
note.favorite = favorite;
|
||||||
await localDB.saveNote(note);
|
await localDB.saveNote(toStoredNote(note));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update favorite status:', error);
|
console.error('Failed to update favorite status:', error);
|
||||||
// Still update locally
|
// Still update locally
|
||||||
note.favorite = favorite;
|
note.favorite = favorite;
|
||||||
await localDB.saveNote(note);
|
await localDB.saveNote(toStoredNote(note));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,14 +364,14 @@ export class SyncManager {
|
|||||||
try {
|
try {
|
||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
const oldId = note.id;
|
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 the note ID changed (due to filename change), delete the old cache entry
|
||||||
if (oldId !== updatedNote.id) {
|
if (oldId !== updatedNote.id) {
|
||||||
await localDB.deleteNote(oldId);
|
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
|
// Protect this note from being deleted by background sync for a short window
|
||||||
this.protectNote(updatedNote.id);
|
this.protectNote(updatedNote.id);
|
||||||
@@ -368,9 +423,9 @@ export class SyncManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.notifyStatus('syncing', 0);
|
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.deleteNote(note.id);
|
||||||
await localDB.saveNote(movedNote);
|
await localDB.saveNote(toStoredNote(movedNote));
|
||||||
|
|
||||||
// Protect the moved note from being deleted by background sync
|
// Protect the moved note from being deleted by background sync
|
||||||
this.protectNote(movedNote.id);
|
this.protectNote(movedNote.id);
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export interface Note {
|
|||||||
modified: number;
|
modified: number;
|
||||||
filename?: string; // WebDAV: actual filename on server
|
filename?: string; // WebDAV: actual filename on server
|
||||||
path?: string; // WebDAV: full path including category
|
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 {
|
export interface APIConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user