Merge feature/webdav-file-access: WebDAV implementation and improvements
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nextcloud-notes-tauri",
|
"name": "nextcloud-notes-tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Nextcloud Notes",
|
"productName": "Nextcloud Notes",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"identifier": "com.davidrelich.nextcloud-notes",
|
"identifier": "com.davidrelich.nextcloud-notes",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -14,7 +14,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('');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -199,4 +199,242 @@ export class NextcloudAPI {
|
|||||||
throw new Error(`Failed to save category colors: ${response.status}`);
|
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 [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);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React from 'react';
|
|||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
import { categoryColorsSync } from '../services/categoryColorsSync';
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
import { SyncStatus } from '../services/syncManager';
|
import { SyncStatus } from '../services/syncManager';
|
||||||
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
|
|
||||||
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;
|
||||||
@@ -37,7 +38,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;
|
||||||
@@ -296,12 +297,19 @@ export function NotesList({
|
|||||||
<span>{formatDate(note.modified)}</span>
|
<span>{formatDate(note.modified)}</span>
|
||||||
{note.category && (() => {
|
{note.category && (() => {
|
||||||
const colors = getCategoryColor(note.category);
|
const colors = getCategoryColor(note.category);
|
||||||
if (!colors) return null;
|
if (colors) {
|
||||||
return (
|
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 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
|
||||||
{note.category}
|
{note.category}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
// Show neutral badge when no color is set
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
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);
|
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,12 +195,11 @@ 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',
|
||||||
@@ -197,7 +216,6 @@ export class SyncManager {
|
|||||||
this.notifyStatus('offline', await this.getPendingCount());
|
this.notifyStatus('offline', await this.getPendingCount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Process sync queue
|
// Process sync queue
|
||||||
async processSyncQueue(): Promise<void> {
|
async processSyncQueue(): Promise<void> {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user