feat: WebDAV file access and category color sync (v0.2.0)

Major Changes:
- Switch from Nextcloud Notes API to direct WebDAV file access
- Notes stored as .txt files with filename-based IDs for reliability
- Implement safer sync strategy without clearNotes() to prevent data loss
- Add ETag-based conflict detection for concurrent edits
- Add category color sync to .category-colors.json on server
- Show neutral gray badges for categories without assigned colors

Technical Improvements:
- Replace numeric IDs with filename-based string IDs
- Update Note type to support both number and string IDs
- Implement WebDAV methods: fetchNotesWebDAV, createNoteWebDAV, updateNoteWebDAV, deleteNoteWebDAV
- Add CategoryColorsSync service for server synchronization
- Remove hash-based color fallback (only show colors when explicitly set)

Bug Fixes:
- Fix category badge rendering to show all categories
- Prevent note loss during sync operations
- Improve offline-first functionality with better merge strategy
This commit is contained in:
drelich
2026-03-25 20:12:00 +01:00
parent 861eb1e103
commit 5a925dc50e
11 changed files with 490 additions and 97 deletions

View File

@@ -8,12 +8,13 @@ import { Note } from './types';
import { syncManager, SyncStatus } from './services/syncManager';
import { localDB } from './db/localDB';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import { categoryColorsSync } from './services/categoryColorsSync';
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('');
@@ -69,6 +70,7 @@ function App() {
});
setApi(apiInstance);
syncManager.setAPI(apiInstance);
categoryColorsSync.setAPI(apiInstance);
setUsername(savedUsername);
setIsLoggedIn(true);
@@ -152,6 +154,7 @@ function App() {
const apiInstance = new NextcloudAPI({ serverURL, username, password });
setApi(apiInstance);
syncManager.setAPI(apiInstance);
categoryColorsSync.setAPI(apiInstance);
setUsername(username);
setIsLoggedIn(true);
};
@@ -164,6 +167,7 @@ function App() {
await localDB.clearSyncQueue();
setApi(null);
syncManager.setAPI(null);
categoryColorsSync.setAPI(null);
setUsername('');
setNotes([]);
setSelectedNoteId(null);

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
@@ -152,4 +152,289 @@ export class NextcloudAPI {
// Return the relative path for markdown
return `${attachmentDir}/${fileName}`;
}
async fetchCategoryColors(): Promise<Record<string, number>> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`;
try {
const response = await tauriFetch(url, {
headers: {
'Authorization': this.authHeader,
},
});
if (!response.ok) {
if (response.status === 404) {
// File doesn't exist yet, return empty object
return {};
}
throw new Error(`Failed to fetch category colors: ${response.status}`);
}
const text = await response.text();
return JSON.parse(text);
} catch (error) {
console.warn('Could not fetch category colors, using empty:', error);
return {};
}
}
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

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { categoryColorsSync } from '../services/categoryColorsSync';
const EDITOR_FONTS = [
{ name: 'Source Code Pro', value: 'Source Code Pro' },
@@ -75,32 +76,28 @@ export function CategoriesSidebar({
const [newCategoryName, setNewCategoryName] = useState('');
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
const [renameCategoryValue, setRenameCategoryValue] = useState('');
const [categoryColors, setCategoryColors] = useState<Record<string, number>>({});
const [categoryColors, setCategoryColors] = useState<Record<string, number>>(() => categoryColorsSync.getAllColors());
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
// Load category colors from localStorage
useEffect(() => {
const saved = localStorage.getItem('categoryColors');
if (saved) {
setCategoryColors(JSON.parse(saved));
}
const handleColorChange = () => {
setCategoryColors(categoryColorsSync.getAllColors());
};
categoryColorsSync.setChangeCallback(handleColorChange);
window.addEventListener('categoryColorChanged', handleColorChange);
return () => {
window.removeEventListener('categoryColorChanged', handleColorChange);
};
}, []);
const setCategoryColor = (category: string, colorIndex: number | null) => {
const updated = { ...categoryColors };
if (colorIndex === null) {
delete updated[category];
} else {
updated[category] = colorIndex;
}
setCategoryColors(updated);
localStorage.setItem('categoryColors', JSON.stringify(updated));
const setCategoryColor = async (category: string, colorIndex: number | null) => {
await categoryColorsSync.setColor(category, colorIndex);
setColorPickerCategory(null);
// Dispatch event to notify other components
window.dispatchEvent(new Event('categoryColorChanged'));
};
useEffect(() => {

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

@@ -1,11 +1,13 @@
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;
@@ -36,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;
@@ -47,20 +49,10 @@ export function NotesList({
// Listen for category color changes
React.useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'categoryColors') {
forceUpdate();
}
};
window.addEventListener('storage', handleStorageChange);
// Also listen for changes in the same tab
const handleCustomEvent = () => forceUpdate();
window.addEventListener('categoryColorChanged', handleCustomEvent);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('categoryColorChanged', handleCustomEvent);
};
}, []);
@@ -156,28 +148,14 @@ export function NotesList({
{ bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300' },
];
// Check for custom color in localStorage first
const savedColors = localStorage.getItem('categoryColors');
if (savedColors) {
try {
const customColors = JSON.parse(savedColors);
if (customColors[category] !== undefined) {
return colors[customColors[category]];
}
} catch (e) {
// Fall through to hash-based color
}
// Only return color if explicitly set by user
const colorIndex = categoryColorsSync.getColor(category);
if (colorIndex !== undefined) {
return colors[colorIndex];
}
// Fall back to hash-based color assignment
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < category.length; i++) {
hash ^= category.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
const index = Math.abs(hash) % colors.length;
return colors[index];
// No color set - return null to indicate no badge should be shown
return null;
};
return (
@@ -319,8 +297,16 @@ export function NotesList({
<span>{formatDate(note.modified)}</span>
{note.category && (() => {
const colors = getCategoryColor(note.category);
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

@@ -0,0 +1,98 @@
import { NextcloudAPI } from '../api/nextcloud';
export class CategoryColorsSync {
private api: NextcloudAPI | null = null;
private colors: Record<string, number> = {};
private syncInProgress: boolean = false;
private changeCallback: (() => void) | null = null;
constructor() {
this.loadFromLocalStorage();
}
setAPI(api: NextcloudAPI | null) {
this.api = api;
if (api) {
this.syncFromServer();
}
}
setChangeCallback(callback: () => void) {
this.changeCallback = callback;
}
private loadFromLocalStorage() {
const saved = localStorage.getItem('categoryColors');
if (saved) {
try {
this.colors = JSON.parse(saved);
} catch (e) {
console.error('Failed to parse category colors from localStorage:', e);
this.colors = {};
}
}
}
private saveToLocalStorage() {
localStorage.setItem('categoryColors', JSON.stringify(this.colors));
}
private notifyChange() {
if (this.changeCallback) {
this.changeCallback();
}
window.dispatchEvent(new Event('categoryColorChanged'));
}
async syncFromServer(): Promise<void> {
if (!this.api || this.syncInProgress) return;
this.syncInProgress = true;
try {
const serverColors = await this.api.fetchCategoryColors();
// Merge: server wins for conflicts
const hasChanges = JSON.stringify(this.colors) !== JSON.stringify(serverColors);
if (hasChanges) {
this.colors = serverColors;
this.saveToLocalStorage();
this.notifyChange();
}
} catch (error) {
console.error('Failed to sync category colors from server:', error);
} finally {
this.syncInProgress = false;
}
}
async setColor(category: string, colorIndex: number | null): Promise<void> {
if (colorIndex === null) {
delete this.colors[category];
} else {
this.colors[category] = colorIndex;
}
this.saveToLocalStorage();
this.notifyChange();
// Sync to server if online
if (this.api) {
try {
await this.api.saveCategoryColors(this.colors);
} catch (error) {
console.error('Failed to save category colors to server:', error);
}
}
}
getColor(category: string): number | undefined {
return this.colors[category];
}
getAllColors(): Record<string, number> {
return { ...this.colors };
}
}
export const categoryColorsSync = new CategoryColorsSync();

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 {