Merge feature/webdav-file-access: WebDAV implementation and improvements

This commit is contained in:
drelich
2026-03-25 20:11:28 +01:00
9 changed files with 320 additions and 51 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "nextcloud-notes-tauri",
"private": true,
"version": "0.1.5",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nextcloud Notes",
"version": "0.1.5",
"version": "0.2.0",
"identifier": "com.davidrelich.nextcloud-notes",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -14,7 +14,7 @@ function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [api, setApi] = useState<NextcloudAPI | null>(null);
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 [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState('');

View File

@@ -61,7 +61,7 @@ export class NextcloudAPI {
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}
// The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL
@@ -102,7 +102,7 @@ export class NextcloudAPI {
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
// Returns the relative path to insert into markdown
@@ -199,4 +199,242 @@ export class NextcloudAPI {
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 [isLoadingImages, setIsLoadingImages] = 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 textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

View File

@@ -2,11 +2,12 @@ import React from 'react';
import { Note } from '../types';
import { categoryColorsSync } from '../services/categoryColorsSync';
import { SyncStatus } from '../services/syncManager';
import { categoryColorsSync } from '../services/categoryColorsSync';
interface NotesListProps {
notes: Note[];
selectedNoteId: number | null;
onSelectNote: (id: number) => void;
selectedNoteId: number | string | null;
onSelectNote: (id: number | string) => void;
onCreateNote: () => void;
onDeleteNote: (note: Note) => void;
onSync: () => void;
@@ -37,7 +38,7 @@ export function NotesList({
isOnline,
}: NotesListProps) {
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 saved = localStorage.getItem('notesListWidth');
return saved ? parseInt(saved, 10) : 320;
@@ -296,9 +297,16 @@ export function NotesList({
<span>{formatDate(note.modified)}</span>
{note.category && (() => {
const colors = getCategoryColor(note.category);
if (!colors) return null;
if (colors) {
return (
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
{note.category}
</span>
);
}
// Show neutral badge when no color is set
return (
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full text-xs font-medium">
{note.category}
</span>
);

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) => {
const store = this.getStore(NOTES_STORE);
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) => {
const store = this.getStore(NOTES_STORE, 'readwrite');
const request = store.delete(id);

View File

@@ -70,16 +70,25 @@ export class SyncManager {
// First, process any pending operations
await this.processSyncQueue();
// Then fetch latest from server
const serverNotes = await this.api.fetchNotes();
// Fetch notes directly from WebDAV (file system)
const serverNotes = await this.api.fetchNotesWebDAV();
const localNotes = await localDB.getAllNotes();
// Merge strategy: server wins for conflicts (last-write-wins based on modified timestamp)
const mergedNotes = this.mergeNotes(localNotes, serverNotes);
// Merge strategy: use file path as unique identifier
const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes);
// Save merged notes to local DB
await localDB.clearNotes();
await localDB.saveNotes(mergedNotes);
// Update local DB with merged notes (no clearNotes - safer!)
for (const note of 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);
} 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 localMap = new Map(localNotes.map(n => [n.id, n]));
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 => {
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 => {
if (!serverMap.has(localNote.id)) {
merged.push(localNote);
@@ -112,9 +129,12 @@ export class SyncManager {
// Create note (offline-first)
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 = {
id: -Date.now(), // Temporary negative ID
id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag: '',
readonly: false,
content,
@@ -175,27 +195,25 @@ export class SyncManager {
}
// Delete note (offline-first)
async deleteNote(id: number): Promise<void> {
async deleteNote(id: number | string): Promise<void> {
// Delete from local DB immediately
await localDB.deleteNote(id);
// Queue for sync (only if it's a real server ID, not temporary)
if (id > 0) {
const operation: SyncOperation = {
id: `delete-${id}-${Date.now()}`,
type: 'delete',
noteId: id,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Queue for sync
const operation: SyncOperation = {
id: `delete-${id}-${Date.now()}`,
type: 'delete',
noteId: id,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
}
@@ -238,28 +256,31 @@ export class SyncManager {
switch (operation.type) {
case 'create':
if (operation.note) {
const serverNote = await this.api.createNote(
const serverNote = await this.api.createNoteWebDAV(
operation.note.title,
operation.note.content,
operation.note.category
);
// Replace temporary note with server note
await localDB.deleteNote(operation.note.id);
// Update local note with server response (etag, etc.)
await localDB.saveNote(serverNote);
}
break;
case 'update':
if (operation.note && operation.note.id > 0) {
const serverNote = await this.api.updateNote(operation.note);
if (operation.note) {
const serverNote = await this.api.updateNoteWebDAV(operation.note);
await localDB.saveNote(serverNote);
}
break;
case 'delete':
if (typeof operation.noteId === 'number' && operation.noteId > 0) {
await this.api.deleteNote(operation.noteId);
if (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;
}

View File

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