From cb7a8d82763df19143041076a9334b567d407a1c Mon Sep 17 00:00:00 2001 From: drelich Date: Wed, 25 Mar 2026 23:31:27 +0100 Subject: [PATCH 1/3] 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 --- src/App.tsx | 40 ++- src/api/nextcloud.ts | 82 +++++-- src/components/NoteEditor.tsx | 114 ++++----- src/components/NotesList.tsx | 19 +- src/db/localDB.ts | 9 +- src/services/syncManager.ts | 450 +++++++++++++++++----------------- 6 files changed, 367 insertions(+), 347 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3c9cb48..c13ef10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,13 +73,6 @@ function App() { categoryColorsSync.setAPI(apiInstance); setUsername(savedUsername); 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); 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(() => { @@ -164,7 +164,6 @@ function App() { localStorage.removeItem('username'); localStorage.removeItem('password'); await localDB.clearNotes(); - await localDB.clearSyncQueue(); setApi(null); syncManager.setAPI(null); categoryColorsSync.setAPI(null); @@ -251,8 +250,27 @@ function App() { const handleUpdateNote = async (updatedNote: Note) => { try { - await syncManager.updateNote(updatedNote); - setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n)); + const originalNote = notes.find(n => n.id === updatedNote.id); + + // If category changed, use moveNote instead of updateNote + if (originalNote && originalNote.category !== updatedNote.category) { + const movedNote = await syncManager.moveNote(originalNote, updatedNote.category); + // If content/title also changed, update the moved note + if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) { + const finalNote = await syncManager.updateNote({ + ...movedNote, + title: updatedNote.title, + content: updatedNote.content, + favorite: updatedNote.favorite, + }); + setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n)); + } 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) { console.error('Update note failed:', error); } @@ -260,7 +278,7 @@ function App() { const handleDeleteNote = async (note: Note) => { try { - await syncManager.deleteNote(note.id); + await syncManager.deleteNote(note); const remainingNotes = notes.filter(n => n.id !== note.id); setNotes(remainingNotes); if (selectedNoteId === note.id) { diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index f3b06d3..289b957 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -112,7 +112,14 @@ export class NextcloudAPI { 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 fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; @@ -202,9 +209,9 @@ export class NextcloudAPI { // WebDAV-based note operations private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note { - const lines = content.split('\n'); - const title = lines[0] || filename.replace('.txt', ''); - const noteContent = lines.slice(1).join('\n').trim(); + // Extract title from first line + const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim(); + const title = firstLine || filename.replace(/\.(md|txt)$/, ''); return { id: `${category}/${filename}`, @@ -212,7 +219,7 @@ export class NextcloudAPI { path: category ? `${category}/${filename}` : filename, etag, readonly: false, - content: noteContent, + content, // Store full content including first line title, category, favorite: false, @@ -221,7 +228,8 @@ export class NextcloudAPI { } 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 { @@ -262,11 +270,11 @@ export class NextcloudAPI { const responseNode = responses[i]; const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || ''; - // Skip if not a .txt file - if (!href.endsWith('.txt')) continue; + // Skip if not a .md or .txt file + if (!href.endsWith('.md') && !href.endsWith('.txt')) continue; // Skip hidden files - const filename = href.split('/').pop() || ''; + const filename = decodeURIComponent(href.split('/').pop() || ''); if (filename.startsWith('.')) continue; const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0]; @@ -276,32 +284,52 @@ export class NextcloudAPI { const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || ''; 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 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 - try { - const fileUrl = `${this.serverURL}${href}`; - const fileResponse = await tauriFetch(fileUrl, { - headers: { 'Authorization': this.authHeader }, - }); - - if (fileResponse.ok) { - const content = await fileResponse.text(); - const note = this.parseNoteFromContent(content, filename, category, etag, modified); - notes.push(note); - } - } catch (error) { - console.error(`Failed to fetch note ${filename}:`, error); - } + // Create note with empty content - will be loaded on-demand + const title = filename.replace(/\.(md|txt)$/, ''); + const note: Note = { + id: category ? `${category}/${filename}` : filename, + filename, + path: category ? `${category}/${filename}` : filename, + etag, + readonly: false, + content: '', // Empty - load on demand + title, + category, + favorite: false, + modified, + }; + notes.push(note); } return notes; } + async fetchNoteContentWebDAV(note: Note): Promise { + 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 { - 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 webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`; const url = `${this.serverURL}${webdavPath}`; diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 07e0411..d988777 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -25,13 +25,11 @@ const imageCache = new Map(); 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 [localCategory, setLocalCategory] = useState(''); const [localFavorite, setLocalFavorite] = useState(false); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [titleManuallyEdited, setTitleManuallyEdited] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false); const [processedContent, setProcessedContent] = useState(''); @@ -140,17 +138,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i useEffect(() => { const loadNewNote = () => { if (note) { - setLocalTitle(note.title); setLocalContent(note.content); setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); 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; previousNoteContentRef.current = note.content; @@ -184,9 +175,14 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i console.log('Last 50 chars:', localContent.slice(-50)); setIsSaving(true); setHasUnsavedChanges(false); + + // Extract title from first line + const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); + const title = firstLine || 'Untitled'; + onUpdateNote({ ...note, - title: localTitle, + title, content: localContent, category: localCategory, favorite: localFavorite, @@ -194,36 +190,18 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i setTimeout(() => setIsSaving(false), 500); }; - const handleTitleChange = (value: string) => { - setLocalTitle(value); - setTitleManuallyEdited(true); - setHasUnsavedChanges(true); - }; - const handleContentChange = (value: string) => { setLocalContent(value); setHasUnsavedChanges(true); - - if (!titleManuallyEdited) { - const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim(); - if (firstLine) { - setLocalTitle(firstLine.substring(0, 50)); - } - } }; const handleDiscard = () => { if (!note) return; - setLocalTitle(note.title); setLocalContent(note.content); setLocalCategory(note.category || ''); setLocalFavorite(note.favorite); 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 => { @@ -344,7 +322,8 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i container.style.color = '#000000'; 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.marginBottom = '20px'; 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 await pdf.html(container, { 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); setTimeout(async () => { @@ -422,9 +402,12 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i const handleFavoriteToggle = () => { setLocalFavorite(!localFavorite); if (note) { + const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); + const title = firstLine || 'Untitled'; + onUpdateNote({ ...note, - title: localTitle, + title, content: localContent, category: localCategory, favorite: !localFavorite, @@ -438,12 +421,24 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i }; const handleAttachmentUpload = async (event: React.ChangeEvent) => { + console.log('handleAttachmentUpload called'); 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); + console.log('Starting upload for file:', file.name, 'to note:', note.id); try { 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 const isImage = file.type.startsWith('image/'); @@ -471,16 +466,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i setHasUnsavedChanges(true); } - await message(`Attachment uploaded successfully!`, { - title: 'Upload Complete', - kind: 'info', - }); + console.log('Attachment uploaded successfully!'); } catch (error) { console.error('Upload failed:', error); - await message(`Failed to upload attachment: ${error}`, { - title: 'Upload Failed', - kind: 'error', - }); + alert(`Failed to upload attachment: ${error}`); } finally { setIsUploading(false); // Reset file input @@ -667,36 +656,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i return (
- {/* Header */} -
-
-
- 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" - /> -
- - -
-
- {/* Toolbar */}
@@ -766,6 +725,21 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i {/* Action Buttons */}
+ + +
+
+ {/* Toolbar */}
@@ -725,21 +766,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i {/* Action Buttons */}
- -