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:
drelich
2026-03-31 09:55:44 +02:00
parent 525413a08a
commit 12b50c2304
5 changed files with 144 additions and 28 deletions

View File

@@ -13,9 +13,9 @@
"windows": [
{
"title": "Nextcloud Notes",
"width": 1200,
"width": 1300,
"height": 800,
"minWidth": 900,
"minWidth": 800,
"minHeight": 600,
"devtools": true
}

View File

@@ -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);

View File

@@ -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 || ''),
};
}
}

View File

@@ -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">

View File

@@ -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();