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:
@@ -13,9 +13,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Nextcloud Notes",
|
"title": "Nextcloud Notes",
|
||||||
"width": 1200,
|
"width": 1300,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 900,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/App.tsx
23
src/App.tsx
@@ -212,16 +212,7 @@ function App() {
|
|||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleCreateNote = async () => {
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toLocaleString('en-US', {
|
const note = await syncManager.createNote('New Note', '', selectedCategory);
|
||||||
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);
|
|
||||||
setNotes([note, ...notes]);
|
setNotes([note, ...notes]);
|
||||||
setSelectedNoteId(note.id);
|
setSelectedNoteId(note.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -275,12 +266,24 @@ function App() {
|
|||||||
favorite: updatedNote.favorite,
|
favorite: updatedNote.favorite,
|
||||||
});
|
});
|
||||||
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
|
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 {
|
} else {
|
||||||
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
|
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 {
|
} else {
|
||||||
const updated = await syncManager.updateNote(updatedNote);
|
const updated = await syncManager.updateNote(updatedNote);
|
||||||
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
|
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) {
|
} catch (error) {
|
||||||
console.error('Update note failed:', error);
|
console.error('Update note failed:', error);
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export class NextcloudAPI {
|
|||||||
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${category}/${filename}`,
|
id: category ? `${category}/${filename}` : filename,
|
||||||
filename,
|
filename,
|
||||||
path: category ? `${category}/${filename}` : filename,
|
path: category ? `${category}/${filename}` : filename,
|
||||||
etag,
|
etag,
|
||||||
@@ -358,7 +358,7 @@ export class NextcloudAPI {
|
|||||||
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
|
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 url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await tauriFetch(url, {
|
||||||
@@ -374,9 +374,9 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()}.md`;
|
const filename = `${title.replace(/[\/:\*?"<>|]/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}/${encodeURIComponent(filename)}`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
// Ensure category directory exists
|
// Ensure category directory exists
|
||||||
@@ -411,7 +411,7 @@ export class NextcloudAPI {
|
|||||||
const modified = Math.floor(Date.now() / 1000);
|
const modified = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${category}/${filename}`,
|
id: category ? `${category}/${filename}` : filename,
|
||||||
filename,
|
filename,
|
||||||
path: category ? `${category}/${filename}` : filename,
|
path: category ? `${category}/${filename}` : filename,
|
||||||
etag,
|
etag,
|
||||||
@@ -425,8 +425,28 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateNoteWebDAV(note: Note): Promise<Note> {
|
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 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 url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const noteContent = this.formatNoteContent(note);
|
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> {
|
async deleteNoteWebDAV(note: Note): Promise<void> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
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 url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await tauriFetch(url, {
|
||||||
@@ -475,8 +548,8 @@ export class NextcloudAPI {
|
|||||||
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
|
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
|
||||||
const oldCategoryPath = note.category ? `/${note.category}` : '';
|
const oldCategoryPath = note.category ? `/${note.category}` : '';
|
||||||
const newCategoryPath = newCategory ? `/${newCategory}` : '';
|
const newCategoryPath = newCategory ? `/${newCategory}` : '';
|
||||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${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}/${note.filename}`;
|
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
|
|
||||||
// Ensure new category directory exists (including nested subdirectories)
|
// Ensure new category directory exists (including nested subdirectories)
|
||||||
if (newCategory) {
|
if (newCategory) {
|
||||||
@@ -547,7 +620,7 @@ export class NextcloudAPI {
|
|||||||
...note,
|
...note,
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
|
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) => {
|
const getPreview = (content: string) => {
|
||||||
// grab first 100 characters of note's content, remove markdown syntax from the preview
|
// Skip first line (title) and find first non-empty line
|
||||||
const previewContent = content.substring(0, 100);
|
const lines = content.split('\n');
|
||||||
const cleanedPreview = previewContent.replace(/[#*`]/g, '');
|
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;
|
return cleanedPreview;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +168,7 @@ export function NotesList({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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"
|
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="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class SyncManager {
|
|||||||
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;
|
private syncCompleteCallback: (() => void) | null = null;
|
||||||
|
private recentlyModifiedNotes: Set<number | string> = new Set();
|
||||||
|
private readonly PROTECTION_WINDOW_MS = 10000;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
@@ -110,12 +112,15 @@ export class SyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deleted notes from cache
|
// Remove deleted notes from cache (but protect recently modified notes)
|
||||||
for (const cachedNote of cachedNotes) {
|
for (const cachedNote of cachedNotes) {
|
||||||
if (!serverMap.has(cachedNote.id)) {
|
if (!serverMap.has(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);
|
await localDB.deleteNote(cachedNote.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sync favorite status from API
|
// Sync favorite status from API
|
||||||
await this.syncFavoriteStatus();
|
await this.syncFavoriteStatus();
|
||||||
@@ -236,6 +241,10 @@ export class SyncManager {
|
|||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
const note = await this.api.createNoteWebDAV(title, content, category);
|
const note = await this.api.createNoteWebDAV(title, content, category);
|
||||||
await localDB.saveNote(note);
|
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);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -297,8 +306,19 @@ export class SyncManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
|
const oldId = note.id;
|
||||||
const updatedNote = await this.api.updateNoteWebDAV(note);
|
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);
|
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);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -349,6 +369,10 @@ export class SyncManager {
|
|||||||
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
|
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
|
||||||
await localDB.deleteNote(note.id);
|
await localDB.deleteNote(note.id);
|
||||||
await localDB.saveNote(movedNote);
|
await localDB.saveNote(movedNote);
|
||||||
|
|
||||||
|
// Protect the moved note from being deleted by background sync
|
||||||
|
this.protectNote(movedNote.id);
|
||||||
|
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -370,6 +394,14 @@ export class SyncManager {
|
|||||||
getOnlineStatus(): boolean {
|
getOnlineStatus(): boolean {
|
||||||
return this.isOnline;
|
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();
|
export const syncManager = new SyncManager();
|
||||||
|
|||||||
Reference in New Issue
Block a user