Major UX improvements: remove title field, auto-sync, fix image uploads

- Remove separate title input field - first line of content is now the title (standard Markdown behavior)
- Update note parsing to extract title from first line while keeping full content
- Move favorite star button to toolbar to save vertical space
- Fix image upload attachment directory path sanitization
- Add automatic background sync after save operations (create, update, move)
- Add rotating sync icon animation during sync operations
- Fix infinite sync loop by preventing sync complete callback from triggering another sync
- Bump IndexedDB version to 2 to clear old cached notes with stripped first lines
- Remove dialog permission errors in attachment upload (use console.log and alert instead)
- Add detailed debug logging for attachment upload troubleshooting
This commit is contained in:
drelich
2026-03-25 23:31:27 +01:00
parent dfc0e644eb
commit cb7a8d8276
6 changed files with 367 additions and 347 deletions

View File

@@ -73,13 +73,6 @@ function App() {
categoryColorsSync.setAPI(apiInstance); categoryColorsSync.setAPI(apiInstance);
setUsername(savedUsername); setUsername(savedUsername);
setIsLoggedIn(true); setIsLoggedIn(true);
// Load notes from local DB immediately
const localNotes = await localDB.getAllNotes();
if (localNotes.length > 0) {
setNotes(localNotes.sort((a, b) => b.modified - a.modified));
setSelectedNoteId(localNotes[0].id);
}
} }
}; };
@@ -115,6 +108,13 @@ function App() {
setSyncStatus(status); setSyncStatus(status);
setPendingSyncCount(count); setPendingSyncCount(count);
}); });
syncManager.setSyncCompleteCallback(async () => {
// 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));
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -164,7 +164,6 @@ function App() {
localStorage.removeItem('username'); localStorage.removeItem('username');
localStorage.removeItem('password'); localStorage.removeItem('password');
await localDB.clearNotes(); await localDB.clearNotes();
await localDB.clearSyncQueue();
setApi(null); setApi(null);
syncManager.setAPI(null); syncManager.setAPI(null);
categoryColorsSync.setAPI(null); categoryColorsSync.setAPI(null);
@@ -251,8 +250,27 @@ function App() {
const handleUpdateNote = async (updatedNote: Note) => { const handleUpdateNote = async (updatedNote: Note) => {
try { try {
await syncManager.updateNote(updatedNote); const originalNote = notes.find(n => n.id === updatedNote.id);
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
// 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));
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
}
} else {
const updated = await syncManager.updateNote(updatedNote);
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
}
} catch (error) { } catch (error) {
console.error('Update note failed:', error); console.error('Update note failed:', error);
} }
@@ -260,7 +278,7 @@ function App() {
const handleDeleteNote = async (note: Note) => { const handleDeleteNote = async (note: Note) => {
try { try {
await syncManager.deleteNote(note.id); await syncManager.deleteNote(note);
const remainingNotes = notes.filter(n => n.id !== note.id); const remainingNotes = notes.filter(n => n.id !== note.id);
setNotes(remainingNotes); setNotes(remainingNotes);
if (selectedNoteId === note.id) { if (selectedNoteId === note.id) {

View File

@@ -112,7 +112,14 @@ export class NextcloudAPI {
webdavPath += `/${noteCategory}`; webdavPath += `/${noteCategory}`;
} }
const attachmentDir = `.attachments.${noteId}`; // Sanitize note ID: extract just the filename without extension and remove invalid chars
// noteId might be "category/filename.md" or just "filename.md"
const noteIdStr = String(noteId);
const justFilename = noteIdStr.split('/').pop() || noteIdStr;
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const attachmentDir = `.attachments.${sanitizedNoteId}`;
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
@@ -202,9 +209,9 @@ export class NextcloudAPI {
// WebDAV-based note operations // WebDAV-based note operations
private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note { private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note {
const lines = content.split('\n'); // Extract title from first line
const title = lines[0] || filename.replace('.txt', ''); const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const noteContent = lines.slice(1).join('\n').trim(); const title = firstLine || filename.replace(/\.(md|txt)$/, '');
return { return {
id: `${category}/${filename}`, id: `${category}/${filename}`,
@@ -212,7 +219,7 @@ export class NextcloudAPI {
path: category ? `${category}/${filename}` : filename, path: category ? `${category}/${filename}` : filename,
etag, etag,
readonly: false, readonly: false,
content: noteContent, content, // Store full content including first line
title, title,
category, category,
favorite: false, favorite: false,
@@ -221,7 +228,8 @@ export class NextcloudAPI {
} }
private formatNoteContent(note: Note): string { private formatNoteContent(note: Note): string {
return `${note.title}\n${note.content}`; // Content already includes the title as first line
return note.content;
} }
async fetchNotesWebDAV(): Promise<Note[]> { async fetchNotesWebDAV(): Promise<Note[]> {
@@ -262,11 +270,11 @@ export class NextcloudAPI {
const responseNode = responses[i]; const responseNode = responses[i];
const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || ''; const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
// Skip if not a .txt file // Skip if not a .md or .txt file
if (!href.endsWith('.txt')) continue; if (!href.endsWith('.md') && !href.endsWith('.txt')) continue;
// Skip hidden files // Skip hidden files
const filename = href.split('/').pop() || ''; const filename = decodeURIComponent(href.split('/').pop() || '');
if (filename.startsWith('.')) continue; if (filename.startsWith('.')) continue;
const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0]; const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0];
@@ -276,32 +284,52 @@ export class NextcloudAPI {
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || ''; const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0; const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0;
// Extract category from path // Extract category from path and decode URL encoding
const pathParts = href.split('/Notes/')[1]?.split('/'); const pathParts = href.split('/Notes/')[1]?.split('/');
const category = pathParts && pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''; const category = pathParts && pathParts.length > 1
? pathParts.slice(0, -1).map(part => decodeURIComponent(part)).join('/')
: '';
// Fetch file content // Create note with empty content - will be loaded on-demand
try { const title = filename.replace(/\.(md|txt)$/, '');
const fileUrl = `${this.serverURL}${href}`; const note: Note = {
const fileResponse = await tauriFetch(fileUrl, { id: category ? `${category}/${filename}` : filename,
headers: { 'Authorization': this.authHeader }, filename,
}); path: category ? `${category}/${filename}` : filename,
etag,
if (fileResponse.ok) { readonly: false,
const content = await fileResponse.text(); content: '', // Empty - load on demand
const note = this.parseNoteFromContent(content, filename, category, etag, modified); title,
notes.push(note); category,
} favorite: false,
} catch (error) { modified,
console.error(`Failed to fetch note ${filename}:`, error); };
} notes.push(note);
} }
return notes; return notes;
} }
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
headers: { 'Authorization': this.authHeader },
});
if (!response.ok) {
throw new Error(`Failed to fetch note content: ${response.status}`);
}
const content = await response.text();
return this.parseNoteFromContent(content, filename, note.category, note.etag, note.modified);
}
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> { async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`; const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`;
const categoryPath = category ? `/${category}` : ''; const categoryPath = category ? `/${category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`; const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;

View File

@@ -25,13 +25,11 @@ const imageCache = new Map<string, string>();
export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localTitle, setLocalTitle] = useState('');
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 [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [titleManuallyEdited, setTitleManuallyEdited] = 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('');
@@ -140,17 +138,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
useEffect(() => { useEffect(() => {
const loadNewNote = () => { const loadNewNote = () => {
if (note) { if (note) {
setLocalTitle(note.title);
setLocalContent(note.content); setLocalContent(note.content);
setLocalCategory(note.category || ''); setLocalCategory(note.category || '');
setLocalFavorite(note.favorite); setLocalFavorite(note.favorite);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
setIsPreviewMode(false);
setProcessedContent(''); // Clear preview content immediately
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
setTitleManuallyEdited(!titleMatchesFirstLine);
previousNoteIdRef.current = note.id; previousNoteIdRef.current = note.id;
previousNoteContentRef.current = note.content; previousNoteContentRef.current = note.content;
@@ -184,9 +175,14 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
console.log('Last 50 chars:', localContent.slice(-50)); console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true); setIsSaving(true);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// Extract title from first line
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({ onUpdateNote({
...note, ...note,
title: localTitle, title,
content: localContent, content: localContent,
category: localCategory, category: localCategory,
favorite: localFavorite, favorite: localFavorite,
@@ -194,36 +190,18 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
setTimeout(() => setIsSaving(false), 500); setTimeout(() => setIsSaving(false), 500);
}; };
const handleTitleChange = (value: string) => {
setLocalTitle(value);
setTitleManuallyEdited(true);
setHasUnsavedChanges(true);
};
const handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
setLocalContent(value); setLocalContent(value);
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
if (!titleManuallyEdited) {
const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim();
if (firstLine) {
setLocalTitle(firstLine.substring(0, 50));
}
}
}; };
const handleDiscard = () => { const handleDiscard = () => {
if (!note) return; if (!note) return;
setLocalTitle(note.title);
setLocalContent(note.content); setLocalContent(note.content);
setLocalCategory(note.category || ''); setLocalCategory(note.category || '');
setLocalFavorite(note.favorite); setLocalFavorite(note.favorite);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
setTitleManuallyEdited(!titleMatchesFirstLine);
}; };
const loadFontAsBase64 = async (fontPath: string): Promise<string> => { const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
@@ -344,7 +322,8 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
container.style.color = '#000000'; container.style.color = '#000000';
const titleElement = document.createElement('h1'); const titleElement = document.createElement('h1');
titleElement.textContent = localTitle || 'Untitled'; const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0'; titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px'; titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px'; titleElement.style.fontSize = '24px';
@@ -385,7 +364,8 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
// Use jsPDF's html() method with custom font set // Use jsPDF's html() method with custom font set
await pdf.html(container, { await pdf.html(container, {
callback: async (doc) => { callback: async (doc) => {
const fileName = `${localTitle || 'note'}.pdf`; const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName); doc.save(fileName);
setTimeout(async () => { setTimeout(async () => {
@@ -422,9 +402,12 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
const handleFavoriteToggle = () => { const handleFavoriteToggle = () => {
setLocalFavorite(!localFavorite); setLocalFavorite(!localFavorite);
if (note) { if (note) {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({ onUpdateNote({
...note, ...note,
title: localTitle, title,
content: localContent, content: localContent,
category: localCategory, category: localCategory,
favorite: !localFavorite, favorite: !localFavorite,
@@ -438,12 +421,24 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
}; };
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
console.log('handleAttachmentUpload called');
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file || !note || !api) return; console.log('File selected:', file?.name);
console.log('Current note ID:', note?.id);
console.log('Current note title:', note?.title);
console.log('Current note category:', note?.category);
console.log('API available:', !!api);
if (!file || !note || !api) {
console.log('Upload aborted - missing:', { file: !!file, note: !!note, api: !!api });
return;
}
setIsUploading(true); setIsUploading(true);
console.log('Starting upload for file:', file.name, 'to note:', note.id);
try { try {
const relativePath = await api.uploadAttachment(note.id, file, note.category); const relativePath = await api.uploadAttachment(note.id, file, note.category);
console.log('Upload successful, path:', relativePath);
// Determine if it's an image or other file // Determine if it's an image or other file
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
@@ -471,16 +466,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
} }
await message(`Attachment uploaded successfully!`, { console.log('Attachment uploaded successfully!');
title: 'Upload Complete',
kind: 'info',
});
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
await message(`Failed to upload attachment: ${error}`, { alert(`Failed to upload attachment: ${error}`);
title: 'Upload Failed',
kind: 'error',
});
} finally { } finally {
setIsUploading(false); setIsUploading(false);
// Reset file input // Reset file input
@@ -667,36 +656,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
return ( return (
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900"> <div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
{/* Header */}
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<input
type="text"
value={localTitle}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Note Title"
className="w-full text-2xl font-semibold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400"
/>
</div>
<button
onClick={handleFavoriteToggle}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors flex-shrink-0"
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
>
<svg
className={`w-5 h-5 ${localFavorite ? 'text-yellow-500 fill-current' : 'text-gray-400 dark:text-gray-500'}`}
fill={localFavorite ? "currentColor" : "none"}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</div>
</div>
{/* Toolbar */} {/* Toolbar */}
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3 bg-gray-50 dark:bg-gray-800/50"> <div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -766,6 +725,21 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-gray-200 dark:border-gray-700"> <div className="flex items-center gap-1 pl-2 border-l border-gray-200 dark:border-gray-700">
<button
onClick={handleFavoriteToggle}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
>
<svg
className={`w-5 h-5 ${localFavorite ? 'text-yellow-500 fill-current' : 'text-gray-600 dark:text-gray-400'}`}
fill={localFavorite ? "currentColor" : "none"}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving} disabled={!hasUnsavedChanges || isSaving}

View File

@@ -32,20 +32,25 @@ export function NotesList({
showFavoritesOnly, showFavoritesOnly,
onToggleFavorites, onToggleFavorites,
hasUnsavedChanges, hasUnsavedChanges,
syncStatus: _syncStatus, syncStatus,
pendingSyncCount, pendingSyncCount,
isOnline, isOnline,
}: NotesListProps) { }: NotesListProps) {
const [isSyncing, setIsSyncing] = React.useState(false);
const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | null>(null); const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | null>(null);
const [width, setWidth] = React.useState(() => { const [width, setWidth] = React.useState(() => {
const saved = localStorage.getItem('notesListWidth'); const saved = localStorage.getItem('notesListWidth');
return saved ? parseInt(saved, 10) : 320; return saved ? parseInt(saved) : 300;
}); });
const [isResizing, setIsResizing] = React.useState(false); const [isResizing, setIsResizing] = React.useState(false);
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const isSyncing = syncStatus === 'syncing';
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const handleSync = async () => {
await onSync();
};
// Listen for category color changes // Listen for category color changes
React.useEffect(() => { React.useEffect(() => {
const handleCustomEvent = () => forceUpdate(); const handleCustomEvent = () => forceUpdate();
@@ -56,12 +61,6 @@ export function NotesList({
}; };
}, []); }, []);
const handleSync = async () => {
setIsSyncing(true);
await onSync();
setTimeout(() => setIsSyncing(false), 500);
};
React.useEffect(() => { React.useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return; if (!isResizing) return;

View File

@@ -1,7 +1,7 @@
import { Note } from '../types'; import { Note } from '../types';
const DB_NAME = 'nextcloud-notes-db'; const DB_NAME = 'nextcloud-notes-db';
const DB_VERSION = 1; const DB_VERSION = 2; // Bumped to clear old cache with URL-encoded categories
const NOTES_STORE = 'notes'; const NOTES_STORE = 'notes';
const SYNC_QUEUE_STORE = 'syncQueue'; const SYNC_QUEUE_STORE = 'syncQueue';
@@ -29,11 +29,18 @@ class LocalDB {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result; const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = event.oldVersion;
if (!db.objectStoreNames.contains(NOTES_STORE)) { if (!db.objectStoreNames.contains(NOTES_STORE)) {
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' }); const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
notesStore.createIndex('modified', 'modified', { unique: false }); notesStore.createIndex('modified', 'modified', { unique: false });
notesStore.createIndex('category', 'category', { unique: false }); notesStore.createIndex('category', 'category', { unique: false });
} else if (oldVersion < 2) {
// Clear notes store when upgrading to v2 to remove old cached notes
// with stripped first lines
const transaction = (event.target as IDBOpenDBRequest).transaction!;
const notesStore = transaction.objectStore(NOTES_STORE);
notesStore.clear();
} }
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) { if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {

View File

@@ -1,6 +1,6 @@
import { Note } from '../types'; import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud'; import { NextcloudAPI } from '../api/nextcloud';
import { localDB, SyncOperation } from '../db/localDB'; import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
@@ -9,12 +9,12 @@ export class SyncManager {
private isOnline: boolean = navigator.onLine; private isOnline: boolean = navigator.onLine;
private syncInProgress: boolean = false; private syncInProgress: boolean = false;
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null; private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
private syncCompleteCallback: (() => void) | null = null;
constructor() { constructor() {
window.addEventListener('online', () => { window.addEventListener('online', () => {
this.isOnline = true; this.isOnline = true;
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
this.processSyncQueue();
}); });
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
@@ -31,259 +31,253 @@ export class SyncManager {
this.statusCallback = callback; this.statusCallback = callback;
} }
setSyncCompleteCallback(callback: () => void) {
this.syncCompleteCallback = callback;
}
private notifyStatus(status: SyncStatus, pendingCount: number) { private notifyStatus(status: SyncStatus, pendingCount: number) {
if (this.statusCallback) { if (this.statusCallback) {
this.statusCallback(status, pendingCount); this.statusCallback(status, pendingCount);
} }
} }
private async getPendingCount(): Promise<number> { // Load notes: cache-first, then sync in background
const queue = await localDB.getSyncQueue();
return queue.length;
}
// Load notes from local DB first, then sync with server
async loadNotes(): Promise<Note[]> { async loadNotes(): Promise<Note[]> {
const localNotes = await localDB.getAllNotes(); // Try to load from cache first (instant)
const cachedNotes = await localDB.getAllNotes();
if (this.isOnline && this.api) { // If we have cached notes and we're offline, return them
try { if (!this.isOnline) {
await this.syncWithServer(); this.notifyStatus('offline', 0);
return await localDB.getAllNotes(); return cachedNotes;
} catch (error) {
console.error('Failed to sync with server, using local data:', error);
return localNotes;
}
} }
return localNotes;
}
// Sync with server: fetch remote notes and merge with local // If we have cached notes, return them immediately
async syncWithServer(): Promise<void> { // Then sync in background
if (!this.api || !this.isOnline || this.syncInProgress) return; if (cachedNotes.length > 0) {
this.syncInBackground();
return cachedNotes;
}
this.syncInProgress = true; // No cache - must fetch from server
this.notifyStatus('syncing', await this.getPendingCount()); if (!this.api) {
throw new Error('API not initialized');
}
try { try {
// First, process any pending operations this.notifyStatus('syncing', 0);
await this.processSyncQueue(); const notes = await this.fetchAndCacheNotes();
this.notifyStatus('idle', 0);
return notes;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Fetch notes directly from WebDAV (file system) // Background sync: compare etags and only fetch changed content
const serverNotes = await this.api.fetchNotesWebDAV(); private async syncInBackground(): Promise<void> {
const localNotes = await localDB.getAllNotes(); if (!this.api || this.syncInProgress) return;
// Merge strategy: use file path as unique identifier this.syncInProgress = true;
const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes); try {
this.notifyStatus('syncing', 0);
// Update local DB with merged notes (no clearNotes - safer!) // Get metadata for all notes (fast - no content)
for (const note of mergedNotes) { const serverNotes = await this.api.fetchNotesWebDAV();
await localDB.saveNote(note); const cachedNotes = await localDB.getAllNotes();
}
// Build maps for comparison
// Remove notes that no longer exist on server const serverMap = new Map(serverNotes.map(n => [n.id, n]));
const serverIds = new Set(serverNotes.map(n => n.id)); const cachedMap = new Map(cachedNotes.map(n => [n.id, n]));
for (const localNote of localNotes) {
if (!serverIds.has(localNote.id) && typeof localNote.id === 'string') { // Find notes that need content fetched (new or changed etag)
await localDB.deleteNote(localNote.id); const notesToFetch: Note[] = [];
for (const serverNote of serverNotes) {
const cached = cachedMap.get(serverNote.id);
if (!cached || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote);
} }
} }
// Fetch content for changed notes
for (const note of notesToFetch) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
}
// Remove deleted notes from cache
for (const cachedNote of cachedNotes) {
if (!serverMap.has(cachedNote.id)) {
await localDB.deleteNote(cachedNote.id);
}
}
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
// Notify that sync is complete so UI can reload
if (this.syncCompleteCallback) {
this.syncCompleteCallback();
}
} catch (error) { } catch (error) {
console.error('Sync failed:', error); console.error('Background sync failed:', error);
this.notifyStatus('error', await this.getPendingCount()); this.notifyStatus('error', 0);
throw error;
} finally { } finally {
this.syncInProgress = false; this.syncInProgress = false;
} }
} }
private mergeNotesWebDAV(localNotes: Note[], serverNotes: Note[]): Note[] { // Fetch all notes and cache them
const serverMap = new Map(serverNotes.map(n => [n.id, n])); private async fetchAndCacheNotes(): Promise<Note[]> {
const localMap = new Map(localNotes.map(n => [n.id, n]));
const merged: Note[] = [];
// Add all server notes (they are the source of truth for existing files)
serverNotes.forEach(serverNote => {
const localNote = localMap.get(serverNote.id);
// If local version is newer, keep it (will be synced later)
if (localNote && localNote.modified > serverNote.modified) {
merged.push(localNote);
} else {
merged.push(serverNote);
}
});
// Add local-only notes (not yet synced to server)
localNotes.forEach(localNote => {
if (!serverMap.has(localNote.id)) {
merged.push(localNote);
}
});
return merged;
}
// Create note (offline-first)
async createNote(title: string, content: string, category: string): Promise<Note> {
// Generate filename-based ID
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`;
const tempNote: Note = {
id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag: '',
readonly: false,
content,
title,
category,
favorite: false,
modified: Math.floor(Date.now() / 1000),
};
// Save to local DB immediately
await localDB.saveNote(tempNote);
// Queue for sync
const operation: SyncOperation = {
id: `create-${tempNote.id}-${Date.now()}`,
type: 'create',
noteId: tempNote.id,
note: tempNote,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
return tempNote;
}
// Update note (offline-first)
async updateNote(note: Note): Promise<Note> {
// Update local DB immediately
await localDB.saveNote(note);
// Queue for sync
const operation: SyncOperation = {
id: `update-${note.id}-${Date.now()}`,
type: 'update',
noteId: note.id,
note,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
return note;
}
// Delete note (offline-first)
async deleteNote(id: number | string): Promise<void> {
// Delete from local DB immediately
await localDB.deleteNote(id);
// Queue for sync
const operation: SyncOperation = {
id: `delete-${id}-${Date.now()}`,
type: 'delete',
noteId: id,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
}
// Process sync queue
async processSyncQueue(): Promise<void> {
if (!this.api || !this.isOnline || this.syncInProgress) return;
const queue = await localDB.getSyncQueue();
if (queue.length === 0) return;
this.syncInProgress = true;
this.notifyStatus('syncing', queue.length);
for (const operation of queue) {
try {
await this.processOperation(operation);
await localDB.removeFromSyncQueue(operation.id);
} catch (error) {
console.error(`Failed to process operation ${operation.id}:`, error);
// Increment retry count
operation.retryCount++;
if (operation.retryCount > 5) {
console.error(`Operation ${operation.id} failed after 5 retries, removing from queue`);
await localDB.removeFromSyncQueue(operation.id);
} else {
await localDB.addToSyncQueue(operation);
}
}
}
this.syncInProgress = false;
const remainingCount = await this.getPendingCount();
this.notifyStatus(remainingCount > 0 ? 'error' : 'idle', remainingCount);
}
private async processOperation(operation: SyncOperation): Promise<void> {
if (!this.api) throw new Error('API not initialized'); if (!this.api) throw new Error('API not initialized');
switch (operation.type) { const serverNotes = await this.api.fetchNotesWebDAV();
case 'create': const notesWithContent: Note[] = [];
if (operation.note) {
const serverNote = await this.api.createNoteWebDAV( for (const note of serverNotes) {
operation.note.title, try {
operation.note.content, const fullNote = await this.api.fetchNoteContentWebDAV(note);
operation.note.category notesWithContent.push(fullNote);
); await localDB.saveNote(fullNote);
} catch (error) {
// Update local note with server response (etag, etc.) console.error(`Failed to fetch note ${note.id}:`, error);
await localDB.saveNote(serverNote); }
}
break;
case 'update':
if (operation.note) {
const serverNote = await this.api.updateNoteWebDAV(operation.note);
await localDB.saveNote(serverNote);
}
break;
case 'delete':
if (operation.noteId) {
// For delete, we need the note object to know the filename
const note = await localDB.getNote(operation.noteId);
if (note) {
await this.api.deleteNoteWebDAV(note);
}
}
break;
} }
return notesWithContent;
}
// Fetch content for a specific note on-demand
async fetchNoteContent(note: Note): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
throw new Error('Cannot fetch note content while offline');
}
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
return fullNote;
} catch (error) {
throw error;
}
}
// Create note on server and cache
async createNote(title: string, content: string, category: string): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot create note while offline');
}
try {
this.notifyStatus('syncing', 0);
const note = await this.api.createNoteWebDAV(title, content, category);
await localDB.saveNote(note);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return note;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Update note on server and cache
async updateNote(note: Note): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot update note while offline');
}
try {
this.notifyStatus('syncing', 0);
const updatedNote = await this.api.updateNoteWebDAV(note);
await localDB.saveNote(updatedNote);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return updatedNote;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Delete note from server and cache
async deleteNote(note: Note): Promise<void> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot delete note while offline');
}
try {
this.notifyStatus('syncing', 0);
await this.api.deleteNoteWebDAV(note);
await localDB.deleteNote(note.id);
this.notifyStatus('idle', 0);
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Move note to different category on server and cache
async moveNote(note: Note, newCategory: string): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot move note while offline');
}
try {
this.notifyStatus('syncing', 0);
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return movedNote;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Manual sync with server
async syncWithServer(): Promise<void> {
if (!this.api || !this.isOnline || this.syncInProgress) return;
await this.syncInBackground();
} }
getOnlineStatus(): boolean { getOnlineStatus(): boolean {