Fix note sync reliability and Unicode filename support
- Fixed critical bug where notes with empty category had malformed IDs (leading slash) - Added URL encoding for Czech and Unicode characters in WebDAV paths - Fixed filename sanitization to preserve Unicode characters (only remove filesystem-unsafe chars) - Updated note preview to show first non-empty line after title instead of repeating title - Adjusted default column widths for better proportions (increased window width to 1300px) - Protection mechanism now works correctly with proper note ID matching
This commit is contained in:
23
src/App.tsx
23
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);
|
||||
|
||||
@@ -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<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 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<Note> {
|
||||
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<Note> {
|
||||
// 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<Note> {
|
||||
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<Note> {
|
||||
const categoryPath = note.category ? `/${note.category}` : '';
|
||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
|
||||
|
||||
const 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<void> {
|
||||
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<Note> {
|
||||
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 || ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col relative flex-shrink-0"
|
||||
style={{ width: `${width}px`, minWidth: '240px', maxWidth: '600px' }}
|
||||
style={{ width: `${width}px`, minWidth: '340px', maxWidth: '600px' }}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -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<number | string> = 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();
|
||||
|
||||
Reference in New Issue
Block a user