From 5a925dc50e5fa9ebf944f32bf3ce699ebe5a0a77 Mon Sep 17 00:00:00 2001 From: drelich Date: Wed, 25 Mar 2026 20:12:00 +0100 Subject: [PATCH 1/2] 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 --- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 6 +- src/api/nextcloud.ts | 289 ++++++++++++++++++++++++++- src/components/CategoriesSidebar.tsx | 31 ++- src/components/NoteEditor.tsx | 2 +- src/components/NotesList.tsx | 54 ++--- src/db/localDB.ts | 4 +- src/services/categoryColorsSync.ts | 98 +++++++++ src/services/syncManager.ts | 95 +++++---- src/types.ts | 4 +- 11 files changed, 490 insertions(+), 97 deletions(-) create mode 100644 src/services/categoryColorsSync.ts diff --git a/package.json b/package.json index 5a75efa..5eb7855 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nextcloud-notes-tauri", "private": true, - "version": "0.1.5", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 28a6337..b53b67a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index f6430a2..3c9cb48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [notes, setNotes] = useState([]); - const [selectedNoteId, setSelectedNoteId] = useState(null); + const [selectedNoteId, setSelectedNoteId] = useState(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); diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index 8243b4b..f3b06d3 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -61,7 +61,7 @@ export class NextcloudAPI { await this.request(`/notes/${id}`, { method: 'DELETE' }); } - async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise { + async fetchAttachment(_noteId: number | string, path: string, noteCategory?: string): Promise { // 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 { + async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise { // 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> { + 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): Promise { + 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 { + 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: ` + + + + + + + + `, + }); + + 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 { + 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 { + 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 { + 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 { + 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}`, + }; + } } diff --git a/src/components/CategoriesSidebar.tsx b/src/components/CategoriesSidebar.tsx index a9a82fa..a44d9df 100644 --- a/src/components/CategoriesSidebar.tsx +++ b/src/components/CategoriesSidebar.tsx @@ -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(null); const [renameCategoryValue, setRenameCategoryValue] = useState(''); - const [categoryColors, setCategoryColors] = useState>({}); + const [categoryColors, setCategoryColors] = useState>(() => categoryColorsSync.getAllColors()); const [colorPickerCategory, setColorPickerCategory] = useState(null); const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true); const inputRef = useRef(null); const renameInputRef = useRef(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(() => { diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index a01a589..07e0411 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -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(null); + const previousNoteIdRef = useRef(null); const previousNoteContentRef = useRef(''); const textareaRef = useRef(null); const fileInputRef = useRef(null); diff --git a/src/components/NotesList.tsx b/src/components/NotesList.tsx index 0533bfd..8e677e9 100644 --- a/src/components/NotesList.tsx +++ b/src/components/NotesList.tsx @@ -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(null); + const [deleteClickedId, setDeleteClickedId] = React.useState(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({ {formatDate(note.modified)} {note.category && (() => { const colors = getCategoryColor(note.category); + if (colors) { + return ( + + {note.category} + + ); + } + // Show neutral badge when no color is set return ( - + {note.category} ); diff --git a/src/db/localDB.ts b/src/db/localDB.ts index 0b20305..ae952db 100644 --- a/src/db/localDB.ts +++ b/src/db/localDB.ts @@ -59,7 +59,7 @@ class LocalDB { }); } - async getNote(id: number): Promise { + async getNote(id: number | string): Promise { 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 { + async deleteNote(id: number | string): Promise { return new Promise((resolve, reject) => { const store = this.getStore(NOTES_STORE, 'readwrite'); const request = store.delete(id); diff --git a/src/services/categoryColorsSync.ts b/src/services/categoryColorsSync.ts new file mode 100644 index 0000000..e569bfb --- /dev/null +++ b/src/services/categoryColorsSync.ts @@ -0,0 +1,98 @@ +import { NextcloudAPI } from '../api/nextcloud'; + +export class CategoryColorsSync { + private api: NextcloudAPI | null = null; + private colors: Record = {}; + 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 { + 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 { + 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 { + return { ...this.colors }; + } +} + +export const categoryColorsSync = new CategoryColorsSync(); diff --git a/src/services/syncManager.ts b/src/services/syncManager.ts index 502d6e7..e26d06a 100644 --- a/src/services/syncManager.ts +++ b/src/services/syncManager.ts @@ -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 { - // 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 { + async deleteNote(id: number | string): Promise { // 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; } diff --git a/src/types.ts b/src/types.ts index 8b84644..014e053 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { From dfc0e644eb0a01fb087b2ed4aba0eb0c54e9641d Mon Sep 17 00:00:00 2001 From: drelich Date: Wed, 25 Mar 2026 20:16:27 +0100 Subject: [PATCH 2/2] fix: remove duplicate import in NotesList.tsx --- src/components/NotesList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/NotesList.tsx b/src/components/NotesList.tsx index 8e677e9..a73818b 100644 --- a/src/components/NotesList.tsx +++ b/src/components/NotesList.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Note } from '../types'; -import { categoryColorsSync } from '../services/categoryColorsSync'; import { SyncStatus } from '../services/syncManager'; import { categoryColorsSync } from '../services/categoryColorsSync';