diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ce53f08..ea41d9c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,9 +13,9 @@ "windows": [ { "title": "Nextcloud Notes", - "width": 1200, + "width": 1300, "height": 800, - "minWidth": 900, + "minWidth": 800, "minHeight": 600, "devtools": true } diff --git a/src/App.tsx b/src/App.tsx index e7500cc..d11e7cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -212,16 +212,7 @@ function App() { const handleCreateNote = async () => { try { - const timestamp = new Date().toLocaleString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).replace(/[/:]/g, '-').replace(', ', ' '); - - const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory); + const note = await syncManager.createNote('New Note', '', selectedCategory); setNotes([note, ...notes]); setSelectedNoteId(note.id); } catch (error) { @@ -275,12 +266,24 @@ function App() { 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); diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index 6c03367..fdffa2f 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -259,7 +259,7 @@ export class NextcloudAPI { const title = firstLine || filename.replace(/\.(md|txt)$/, ''); return { - id: `${category}/${filename}`, + id: category ? `${category}/${filename}` : filename, filename, path: category ? `${category}/${filename}` : filename, etag, @@ -358,7 +358,7 @@ export class NextcloudAPI { 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 webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; const url = `${this.serverURL}${webdavPath}`; const response = await tauriFetch(url, { @@ -374,9 +374,9 @@ export class NextcloudAPI { } async createNoteWebDAV(title: string, content: string, category: string): Promise { - const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`; + const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`; 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}/${encodeURIComponent(filename)}`; const url = `${this.serverURL}${webdavPath}`; // Ensure category directory exists @@ -411,7 +411,7 @@ export class NextcloudAPI { const modified = Math.floor(Date.now() / 1000); return { - id: `${category}/${filename}`, + id: category ? `${category}/${filename}` : filename, filename, path: category ? `${category}/${filename}` : filename, etag, @@ -425,8 +425,28 @@ export class NextcloudAPI { } async updateNoteWebDAV(note: Note): Promise { + // Extract new title from first line of content + const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); + const newTitle = firstLine || 'New Note'; + const newFilename = `${newTitle.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`; + + // Check if filename needs to change + const needsRename = note.filename !== newFilename; + + if (needsRename) { + // Rename the file first, then update content + const renamedNote = await this.renameNoteWebDAV(note, newFilename); + // Now update the content of the renamed file + return this.updateNoteContentWebDAV(renamedNote); + } else { + // Just update content + return this.updateNoteContentWebDAV(note); + } + } + + private async updateNoteContentWebDAV(note: Note): Promise { const categoryPath = note.category ? `/${note.category}` : ''; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${note.filename}`; + const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; const url = `${this.serverURL}${webdavPath}`; const noteContent = this.formatNoteContent(note); @@ -457,9 +477,62 @@ export class NextcloudAPI { }; } + private async renameNoteWebDAV(note: Note, newFilename: string): Promise { + const categoryPath = note.category ? `/${note.category}` : ''; + const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; + const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`; + + const response = await tauriFetch(`${this.serverURL}${oldPath}`, { + method: 'MOVE', + headers: { + 'Authorization': this.authHeader, + 'Destination': `${this.serverURL}${newPath}`, + }, + }); + + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`Failed to rename note: ${response.status}`); + } + + // Also rename attachment folder if it exists + const oldNoteIdStr = String(note.id); + const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; + const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); + const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`; + + const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, ''); + const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`; + + const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`; + const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`; + + try { + await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, { + method: 'MOVE', + headers: { + 'Authorization': this.authHeader, + 'Destination': `${this.serverURL}${newAttachmentPath}`, + }, + }); + } catch (e) { + // Attachment folder might not exist, that's ok + } + + const newId = note.category ? `${note.category}/${newFilename}` : newFilename; + + return { + ...note, + id: newId, + filename: newFilename, + path: note.category ? `${note.category}/${newFilename}` : newFilename, + }; + } + async deleteNoteWebDAV(note: Note): Promise { const categoryPath = note.category ? `/${note.category}` : ''; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${note.filename}`; + const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; const url = `${this.serverURL}${webdavPath}`; const response = await tauriFetch(url, { @@ -475,8 +548,8 @@ export class NextcloudAPI { async moveNoteWebDAV(note: Note, newCategory: string): Promise { const oldCategoryPath = note.category ? `/${note.category}` : ''; const newCategoryPath = newCategory ? `/${newCategory}` : ''; - const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${note.filename}`; - const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${note.filename}`; + const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`; + const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`; // Ensure new category directory exists (including nested subdirectories) if (newCategory) { @@ -547,7 +620,7 @@ export class NextcloudAPI { ...note, category: newCategory, path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '', - id: `${newCategory}/${note.filename}`, + id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''), }; } } diff --git a/src/components/NotesList.tsx b/src/components/NotesList.tsx index fa2cd61..079f829 100644 --- a/src/components/NotesList.tsx +++ b/src/components/NotesList.tsx @@ -125,9 +125,17 @@ export function NotesList({ }; const getPreview = (content: string) => { - // grab first 100 characters of note's content, remove markdown syntax from the preview - const previewContent = content.substring(0, 100); - const cleanedPreview = previewContent.replace(/[#*`]/g, ''); + // Skip first line (title) and find first non-empty line + const lines = content.split('\n'); + const contentLines = lines.slice(1); // Skip first line + + // Find first non-empty line + const firstContentLine = contentLines.find(line => line.trim().length > 0); + if (!firstContentLine) return ''; + + // Take up to 100 characters from the content lines + const previewContent = contentLines.join(' ').substring(0, 100); + const cleanedPreview = previewContent.replace(/[#*`]/g, '').trim(); return cleanedPreview; }; @@ -160,7 +168,7 @@ export function NotesList({
diff --git a/src/services/syncManager.ts b/src/services/syncManager.ts index ad7f324..0801abb 100644 --- a/src/services/syncManager.ts +++ b/src/services/syncManager.ts @@ -10,6 +10,8 @@ export class SyncManager { private syncInProgress: boolean = false; private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null; private syncCompleteCallback: (() => void) | null = null; + private recentlyModifiedNotes: Set = new Set(); + private readonly PROTECTION_WINDOW_MS = 10000; constructor() { window.addEventListener('online', () => { @@ -110,10 +112,13 @@ export class SyncManager { } } - // Remove deleted notes from cache + // Remove deleted notes from cache (but protect recently modified notes) for (const cachedNote of cachedNotes) { if (!serverMap.has(cachedNote.id)) { - await localDB.deleteNote(cachedNote.id); + // Don't delete notes that were recently created/updated (race condition protection) + if (!this.recentlyModifiedNotes.has(cachedNote.id)) { + await localDB.deleteNote(cachedNote.id); + } } } @@ -236,6 +241,10 @@ export class SyncManager { this.notifyStatus('syncing', 0); const note = await this.api.createNoteWebDAV(title, content, category); await localDB.saveNote(note); + + // Protect this note from being deleted by background sync for a short window + this.protectNote(note.id); + this.notifyStatus('idle', 0); // Trigger background sync to fetch any other changes @@ -297,8 +306,19 @@ export class SyncManager { try { this.notifyStatus('syncing', 0); + const oldId = note.id; const updatedNote = await this.api.updateNoteWebDAV(note); + + // If the note ID changed (due to filename change), delete the old cache entry + if (oldId !== updatedNote.id) { + await localDB.deleteNote(oldId); + } + await localDB.saveNote(updatedNote); + + // Protect this note from being deleted by background sync for a short window + this.protectNote(updatedNote.id); + this.notifyStatus('idle', 0); // Trigger background sync to fetch any other changes @@ -349,6 +369,10 @@ export class SyncManager { const movedNote = await this.api.moveNoteWebDAV(note, newCategory); await localDB.deleteNote(note.id); await localDB.saveNote(movedNote); + + // Protect the moved note from being deleted by background sync + this.protectNote(movedNote.id); + this.notifyStatus('idle', 0); // Trigger background sync to fetch any other changes @@ -370,6 +394,14 @@ export class SyncManager { getOnlineStatus(): boolean { return this.isOnline; } + + // Protect a note from being deleted during background sync for a short window + private protectNote(noteId: number | string): void { + this.recentlyModifiedNotes.add(noteId); + setTimeout(() => { + this.recentlyModifiedNotes.delete(noteId); + }, this.PROTECTION_WINDOW_MS); + } } export const syncManager = new SyncManager();