feat: switch from Notes API to WebDAV file access

- Replace API-based note operations with direct WebDAV file access
- Use filename-based IDs instead of numeric IDs for better reliability
- Implement safer merge strategy that doesn't clear local notes
- Add ETag-based conflict detection to prevent data loss
- Support string | number IDs throughout the codebase
- Notes are now stored as .txt files in /Notes/{category}/
- Eliminates race conditions and temporary ID conflicts
- More reliable sync with direct file system access
This commit is contained in:
drelich
2026-03-25 19:47:00 +01:00
parent 861eb1e103
commit 5de3cd3789
7 changed files with 328 additions and 47 deletions

View File

@@ -13,7 +13,7 @@ function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [api, setApi] = useState<NextcloudAPI | null>(null); const [api, setApi] = useState<NextcloudAPI | null>(null);
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [selectedNoteId, setSelectedNoteId] = useState<number | null>(null); const [selectedNoteId, setSelectedNoteId] = useState<number | string | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');

View File

@@ -61,7 +61,7 @@ export class NextcloudAPI {
await this.request<void>(`/notes/${id}`, { method: 'DELETE' }); await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
} }
async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise<string> { async fetchAttachment(_noteId: number | string, path: string, noteCategory?: string): Promise<string> {
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename} // Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
// The path from markdown is like: .attachments.38479/Screenshot.png // The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL // We need to construct the full WebDAV URL
@@ -102,7 +102,7 @@ export class NextcloudAPI {
return this.serverURL; return this.serverURL;
} }
async uploadAttachment(noteId: number, file: File, noteCategory?: string): Promise<string> { async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise<string> {
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT // Create .attachments.{noteId} directory path and upload file via WebDAV PUT
// Returns the relative path to insert into markdown // Returns the relative path to insert into markdown
@@ -152,4 +152,262 @@ export class NextcloudAPI {
// Return the relative path for markdown // Return the relative path for markdown
return `${attachmentDir}/${fileName}`; return `${attachmentDir}/${fileName}`;
} }
async saveCategoryColors(colors: Record<string, number>): Promise<void> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`;
const content = JSON.stringify(colors, null, 2);
const response = await tauriFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
},
body: content,
});
if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to save category colors: ${response.status}`);
}
}
// 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();
return {
id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag,
readonly: false,
content: noteContent,
title,
category,
favorite: false,
modified,
};
}
private formatNoteContent(note: Note): string {
return `${note.title}\n${note.content}`;
}
async fetchNotesWebDAV(): Promise<Note[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
'Depth': 'infinity',
'Content-Type': 'application/xml',
},
body: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:resourcetype/>
</d:prop>
</d:propfind>`,
});
if (!response.ok) {
throw new Error(`Failed to list notes: ${response.status}`);
}
const xmlText = await response.text();
const notes: Note[] = [];
// Parse XML response
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
const responses = xmlDoc.getElementsByTagNameNS('DAV:', 'response');
for (let i = 0; i < responses.length; i++) {
const responseNode = responses[i];
const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
// Skip if not a .txt file
if (!href.endsWith('.txt')) continue;
// Skip hidden files
const filename = href.split('/').pop() || '';
if (filename.startsWith('.')) continue;
const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0];
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0;
// Extract category from path
const pathParts = href.split('/Notes/')[1]?.split('/');
const category = pathParts && pathParts.length > 1 ? pathParts.slice(0, -1).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);
}
}
return notes;
}
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`;
const categoryPath = category ? `/${category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
const url = `${this.serverURL}${webdavPath}`;
// Ensure category directory exists
if (category) {
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
}
const noteContent = `${title}\n${content}`;
const response = await tauriFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'text/plain',
},
body: noteContent,
});
if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to create note: ${response.status}`);
}
const etag = response.headers.get('etag') || '';
const modified = Math.floor(Date.now() / 1000);
return {
id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag,
readonly: false,
content,
title,
category,
favorite: false,
modified,
};
}
async updateNoteWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${note.filename}`;
const url = `${this.serverURL}${webdavPath}`;
const noteContent = this.formatNoteContent(note);
const response = await tauriFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'text/plain',
'If-Match': note.etag, // Prevent overwriting if file changed
},
body: noteContent,
});
if (!response.ok && response.status !== 204) {
if (response.status === 412) {
throw new Error('Note was modified by another client. Please refresh.');
}
throw new Error(`Failed to update note: ${response.status}`);
}
const etag = response.headers.get('etag') || note.etag;
return {
...note,
etag,
modified: Math.floor(Date.now() / 1000),
};
}
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 url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
method: 'DELETE',
headers: { 'Authorization': this.authHeader },
});
if (!response.ok && response.status !== 204) {
throw new Error(`Failed to delete note: ${response.status}`);
}
}
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}`;
// Ensure new category directory exists
if (newCategory) {
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${newCategory}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
}
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 move note: ${response.status}`);
}
return {
...note,
category: newCategory,
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
id: `${newCategory}/${note.filename}`,
};
}
} }

View File

@@ -37,7 +37,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
const [processedContent, setProcessedContent] = useState(''); const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false); const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const previousNoteIdRef = useRef<number | null>(null); const previousNoteIdRef = useRef<number | string | null>(null);
const previousNoteContentRef = useRef<string>(''); const previousNoteContentRef = useRef<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);

View File

@@ -4,8 +4,8 @@ import { SyncStatus } from '../services/syncManager';
interface NotesListProps { interface NotesListProps {
notes: Note[]; notes: Note[];
selectedNoteId: number | null; selectedNoteId: number | string | null;
onSelectNote: (id: number) => void; onSelectNote: (id: number | string) => void;
onCreateNote: () => void; onCreateNote: () => void;
onDeleteNote: (note: Note) => void; onDeleteNote: (note: Note) => void;
onSync: () => void; onSync: () => void;
@@ -36,7 +36,7 @@ export function NotesList({
isOnline, isOnline,
}: NotesListProps) { }: NotesListProps) {
const [isSyncing, setIsSyncing] = React.useState(false); const [isSyncing, setIsSyncing] = React.useState(false);
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null); const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | null>(null);
const [width, setWidth] = React.useState(() => { const [width, setWidth] = React.useState(() => {
const saved = localStorage.getItem('notesListWidth'); const saved = localStorage.getItem('notesListWidth');
return saved ? parseInt(saved, 10) : 320; return saved ? parseInt(saved, 10) : 320;

View File

@@ -59,7 +59,7 @@ class LocalDB {
}); });
} }
async getNote(id: number): Promise<Note | undefined> { async getNote(id: number | string): Promise<Note | undefined> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this.getStore(NOTES_STORE); const store = this.getStore(NOTES_STORE);
const request = store.get(id); const request = store.get(id);
@@ -89,7 +89,7 @@ class LocalDB {
}); });
} }
async deleteNote(id: number): Promise<void> { async deleteNote(id: number | string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this.getStore(NOTES_STORE, 'readwrite'); const store = this.getStore(NOTES_STORE, 'readwrite');
const request = store.delete(id); const request = store.delete(id);

View File

@@ -70,16 +70,25 @@ export class SyncManager {
// First, process any pending operations // First, process any pending operations
await this.processSyncQueue(); await this.processSyncQueue();
// Then fetch latest from server // Fetch notes directly from WebDAV (file system)
const serverNotes = await this.api.fetchNotes(); const serverNotes = await this.api.fetchNotesWebDAV();
const localNotes = await localDB.getAllNotes(); const localNotes = await localDB.getAllNotes();
// Merge strategy: server wins for conflicts (last-write-wins based on modified timestamp) // Merge strategy: use file path as unique identifier
const mergedNotes = this.mergeNotes(localNotes, serverNotes); const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes);
// Save merged notes to local DB // Update local DB with merged notes (no clearNotes - safer!)
await localDB.clearNotes(); for (const note of mergedNotes) {
await localDB.saveNotes(mergedNotes); await localDB.saveNote(note);
}
// Remove notes that no longer exist on server
const serverIds = new Set(serverNotes.map(n => n.id));
for (const localNote of localNotes) {
if (!serverIds.has(localNote.id) && typeof localNote.id === 'string') {
await localDB.deleteNote(localNote.id);
}
}
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
} catch (error) { } catch (error) {
@@ -91,16 +100,24 @@ export class SyncManager {
} }
} }
private mergeNotes(localNotes: Note[], serverNotes: Note[]): Note[] { private mergeNotesWebDAV(localNotes: Note[], serverNotes: Note[]): Note[] {
const serverMap = new Map(serverNotes.map(n => [n.id, n])); const serverMap = new Map(serverNotes.map(n => [n.id, n]));
const localMap = new Map(localNotes.map(n => [n.id, n]));
const merged: Note[] = []; const merged: Note[] = [];
// Add all server notes (they are the source of truth) // Add all server notes (they are the source of truth for existing files)
serverNotes.forEach(serverNote => { serverNotes.forEach(serverNote => {
merged.push(serverNote); const localNote = localMap.get(serverNote.id);
// If local version is newer, keep it (will be synced later)
if (localNote && localNote.modified > serverNote.modified) {
merged.push(localNote);
} else {
merged.push(serverNote);
}
}); });
// Add local-only notes (not yet synced, likely have temporary IDs) // Add local-only notes (not yet synced to server)
localNotes.forEach(localNote => { localNotes.forEach(localNote => {
if (!serverMap.has(localNote.id)) { if (!serverMap.has(localNote.id)) {
merged.push(localNote); merged.push(localNote);
@@ -112,9 +129,12 @@ export class SyncManager {
// Create note (offline-first) // Create note (offline-first)
async createNote(title: string, content: string, category: string): Promise<Note> { async createNote(title: string, content: string, category: string): Promise<Note> {
// Create temporary note with negative ID for offline mode // Generate filename-based ID
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`;
const tempNote: Note = { const tempNote: Note = {
id: -Date.now(), // Temporary negative ID id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag: '', etag: '',
readonly: false, readonly: false,
content, content,
@@ -175,27 +195,25 @@ export class SyncManager {
} }
// Delete note (offline-first) // Delete note (offline-first)
async deleteNote(id: number): Promise<void> { async deleteNote(id: number | string): Promise<void> {
// Delete from local DB immediately // Delete from local DB immediately
await localDB.deleteNote(id); await localDB.deleteNote(id);
// Queue for sync (only if it's a real server ID, not temporary) // Queue for sync
if (id > 0) { const operation: SyncOperation = {
const operation: SyncOperation = { id: `delete-${id}-${Date.now()}`,
id: `delete-${id}-${Date.now()}`, type: 'delete',
type: 'delete', noteId: id,
noteId: id, timestamp: Date.now(),
timestamp: Date.now(), retryCount: 0,
retryCount: 0, };
}; await localDB.addToSyncQueue(operation);
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online // Try to sync immediately if online
if (this.isOnline && this.api) { if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error); this.processSyncQueue().catch(console.error);
} else { } else {
this.notifyStatus('offline', await this.getPendingCount()); this.notifyStatus('offline', await this.getPendingCount());
}
} }
} }
@@ -238,28 +256,31 @@ export class SyncManager {
switch (operation.type) { switch (operation.type) {
case 'create': case 'create':
if (operation.note) { if (operation.note) {
const serverNote = await this.api.createNote( const serverNote = await this.api.createNoteWebDAV(
operation.note.title, operation.note.title,
operation.note.content, operation.note.content,
operation.note.category operation.note.category
); );
// Replace temporary note with server note // Update local note with server response (etag, etc.)
await localDB.deleteNote(operation.note.id);
await localDB.saveNote(serverNote); await localDB.saveNote(serverNote);
} }
break; break;
case 'update': case 'update':
if (operation.note && operation.note.id > 0) { if (operation.note) {
const serverNote = await this.api.updateNote(operation.note); const serverNote = await this.api.updateNoteWebDAV(operation.note);
await localDB.saveNote(serverNote); await localDB.saveNote(serverNote);
} }
break; break;
case 'delete': case 'delete':
if (typeof operation.noteId === 'number' && operation.noteId > 0) { if (operation.noteId) {
await this.api.deleteNote(operation.noteId); // For delete, we need the note object to know the filename
const note = await localDB.getNote(operation.noteId);
if (note) {
await this.api.deleteNoteWebDAV(note);
}
} }
break; break;
} }

View File

@@ -1,5 +1,5 @@
export interface Note { export interface Note {
id: number; id: number | string; // number for API, string (filename) for WebDAV
etag: string; etag: string;
readonly: boolean; readonly: boolean;
content: string; content: string;
@@ -7,6 +7,8 @@ export interface Note {
category: string; category: string;
favorite: boolean; favorite: boolean;
modified: number; modified: number;
filename?: string; // WebDAV: actual filename on server
path?: string; // WebDAV: full path including category
} }
export interface APIConfig { export interface APIConfig {