Merge feature/webdav-file-access: WebDAV implementation and improvements
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nextcloud-notes-tauri",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user