Merge branch 'main' into dev
This commit is contained in:
40
src/App.tsx
40
src/App.tsx
@@ -73,13 +73,6 @@ function App() {
|
|||||||
categoryColorsSync.setAPI(apiInstance);
|
categoryColorsSync.setAPI(apiInstance);
|
||||||
setUsername(savedUsername);
|
setUsername(savedUsername);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
|
|
||||||
// Load notes from local DB immediately
|
|
||||||
const localNotes = await localDB.getAllNotes();
|
|
||||||
if (localNotes.length > 0) {
|
|
||||||
setNotes(localNotes.sort((a, b) => b.modified - a.modified));
|
|
||||||
setSelectedNoteId(localNotes[0].id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +108,13 @@ function App() {
|
|||||||
setSyncStatus(status);
|
setSyncStatus(status);
|
||||||
setPendingSyncCount(count);
|
setPendingSyncCount(count);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
syncManager.setSyncCompleteCallback(async () => {
|
||||||
|
// Reload notes from cache after background sync completes
|
||||||
|
// Don't call loadNotes() as it triggers another sync - just reload from cache
|
||||||
|
const cachedNotes = await localDB.getAllNotes();
|
||||||
|
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,7 +164,6 @@ function App() {
|
|||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('password');
|
localStorage.removeItem('password');
|
||||||
await localDB.clearNotes();
|
await localDB.clearNotes();
|
||||||
await localDB.clearSyncQueue();
|
|
||||||
setApi(null);
|
setApi(null);
|
||||||
syncManager.setAPI(null);
|
syncManager.setAPI(null);
|
||||||
categoryColorsSync.setAPI(null);
|
categoryColorsSync.setAPI(null);
|
||||||
@@ -251,8 +250,27 @@ function App() {
|
|||||||
|
|
||||||
const handleUpdateNote = async (updatedNote: Note) => {
|
const handleUpdateNote = async (updatedNote: Note) => {
|
||||||
try {
|
try {
|
||||||
await syncManager.updateNote(updatedNote);
|
const originalNote = notes.find(n => n.id === updatedNote.id);
|
||||||
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
|
|
||||||
|
// If category changed, use moveNote instead of updateNote
|
||||||
|
if (originalNote && originalNote.category !== updatedNote.category) {
|
||||||
|
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
|
||||||
|
// If content/title also changed, update the moved note
|
||||||
|
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) {
|
||||||
|
const finalNote = await syncManager.updateNote({
|
||||||
|
...movedNote,
|
||||||
|
title: updatedNote.title,
|
||||||
|
content: updatedNote.content,
|
||||||
|
favorite: updatedNote.favorite,
|
||||||
|
});
|
||||||
|
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
|
||||||
|
} else {
|
||||||
|
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const updated = await syncManager.updateNote(updatedNote);
|
||||||
|
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update note failed:', error);
|
console.error('Update note failed:', error);
|
||||||
}
|
}
|
||||||
@@ -260,7 +278,7 @@ function App() {
|
|||||||
|
|
||||||
const handleDeleteNote = async (note: Note) => {
|
const handleDeleteNote = async (note: Note) => {
|
||||||
try {
|
try {
|
||||||
await syncManager.deleteNote(note.id);
|
await syncManager.deleteNote(note);
|
||||||
const remainingNotes = notes.filter(n => n.id !== note.id);
|
const remainingNotes = notes.filter(n => n.id !== note.id);
|
||||||
setNotes(remainingNotes);
|
setNotes(remainingNotes);
|
||||||
if (selectedNoteId === note.id) {
|
if (selectedNoteId === note.id) {
|
||||||
|
|||||||
@@ -112,7 +112,14 @@ export class NextcloudAPI {
|
|||||||
webdavPath += `/${noteCategory}`;
|
webdavPath += `/${noteCategory}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentDir = `.attachments.${noteId}`;
|
// Sanitize note ID: extract just the filename without extension and remove invalid chars
|
||||||
|
// noteId might be "category/filename.md" or just "filename.md"
|
||||||
|
const noteIdStr = String(noteId);
|
||||||
|
const justFilename = noteIdStr.split('/').pop() || noteIdStr;
|
||||||
|
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
|
const attachmentDir = `.attachments.${sanitizedNoteId}`;
|
||||||
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
|
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
|
||||||
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
||||||
|
|
||||||
@@ -202,9 +209,9 @@ export class NextcloudAPI {
|
|||||||
|
|
||||||
// WebDAV-based note operations
|
// WebDAV-based note operations
|
||||||
private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note {
|
private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note {
|
||||||
const lines = content.split('\n');
|
// Extract title from first line
|
||||||
const title = lines[0] || filename.replace('.txt', '');
|
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
const noteContent = lines.slice(1).join('\n').trim();
|
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${category}/${filename}`,
|
id: `${category}/${filename}`,
|
||||||
@@ -212,7 +219,7 @@ export class NextcloudAPI {
|
|||||||
path: category ? `${category}/${filename}` : filename,
|
path: category ? `${category}/${filename}` : filename,
|
||||||
etag,
|
etag,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
content: noteContent,
|
content, // Store full content including first line
|
||||||
title,
|
title,
|
||||||
category,
|
category,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
@@ -221,7 +228,8 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatNoteContent(note: Note): string {
|
private formatNoteContent(note: Note): string {
|
||||||
return `${note.title}\n${note.content}`;
|
// Content already includes the title as first line
|
||||||
|
return note.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchNotesWebDAV(): Promise<Note[]> {
|
async fetchNotesWebDAV(): Promise<Note[]> {
|
||||||
@@ -262,11 +270,11 @@ export class NextcloudAPI {
|
|||||||
const responseNode = responses[i];
|
const responseNode = responses[i];
|
||||||
const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
|
const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
|
||||||
|
|
||||||
// Skip if not a .txt file
|
// Skip if not a .md or .txt file
|
||||||
if (!href.endsWith('.txt')) continue;
|
if (!href.endsWith('.md') && !href.endsWith('.txt')) continue;
|
||||||
|
|
||||||
// Skip hidden files
|
// Skip hidden files
|
||||||
const filename = href.split('/').pop() || '';
|
const filename = decodeURIComponent(href.split('/').pop() || '');
|
||||||
if (filename.startsWith('.')) continue;
|
if (filename.startsWith('.')) continue;
|
||||||
|
|
||||||
const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
||||||
@@ -276,32 +284,52 @@ export class NextcloudAPI {
|
|||||||
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
|
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
|
||||||
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0;
|
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0;
|
||||||
|
|
||||||
// Extract category from path
|
// Extract category from path and decode URL encoding
|
||||||
const pathParts = href.split('/Notes/')[1]?.split('/');
|
const pathParts = href.split('/Notes/')[1]?.split('/');
|
||||||
const category = pathParts && pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
|
const category = pathParts && pathParts.length > 1
|
||||||
|
? pathParts.slice(0, -1).map(part => decodeURIComponent(part)).join('/')
|
||||||
|
: '';
|
||||||
|
|
||||||
// Fetch file content
|
// Create note with empty content - will be loaded on-demand
|
||||||
try {
|
const title = filename.replace(/\.(md|txt)$/, '');
|
||||||
const fileUrl = `${this.serverURL}${href}`;
|
const note: Note = {
|
||||||
const fileResponse = await tauriFetch(fileUrl, {
|
id: category ? `${category}/${filename}` : filename,
|
||||||
headers: { 'Authorization': this.authHeader },
|
filename,
|
||||||
});
|
path: category ? `${category}/${filename}` : filename,
|
||||||
|
etag,
|
||||||
if (fileResponse.ok) {
|
readonly: false,
|
||||||
const content = await fileResponse.text();
|
content: '', // Empty - load on demand
|
||||||
const note = this.parseNoteFromContent(content, filename, category, etag, modified);
|
title,
|
||||||
|
category,
|
||||||
|
favorite: false,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
notes.push(note);
|
notes.push(note);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch note ${filename}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return notes;
|
return notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const response = await tauriFetch(url, {
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch note content: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
return this.parseNoteFromContent(content, filename, note.category, note.etag, note.modified);
|
||||||
|
}
|
||||||
|
|
||||||
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
|
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 filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
||||||
const categoryPath = category ? `/${category}` : '';
|
const categoryPath = category ? `/${category}` : '';
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|||||||
@@ -33,20 +33,25 @@ export function NotesList({
|
|||||||
showFavoritesOnly,
|
showFavoritesOnly,
|
||||||
onToggleFavorites,
|
onToggleFavorites,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
syncStatus: _syncStatus,
|
syncStatus,
|
||||||
pendingSyncCount,
|
pendingSyncCount,
|
||||||
isOnline,
|
isOnline,
|
||||||
}: NotesListProps) {
|
}: NotesListProps) {
|
||||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
|
||||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | 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) : 300;
|
||||||
});
|
});
|
||||||
const [isResizing, setIsResizing] = React.useState(false);
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
await onSync();
|
||||||
|
};
|
||||||
|
|
||||||
// Listen for category color changes
|
// Listen for category color changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleCustomEvent = () => forceUpdate();
|
const handleCustomEvent = () => forceUpdate();
|
||||||
@@ -57,12 +62,6 @@ export function NotesList({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
await onSync();
|
|
||||||
setTimeout(() => setIsSyncing(false), 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
|
|
||||||
const DB_NAME = 'nextcloud-notes-db';
|
const DB_NAME = 'nextcloud-notes-db';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2; // Bumped to clear old cache with URL-encoded categories
|
||||||
const NOTES_STORE = 'notes';
|
const NOTES_STORE = 'notes';
|
||||||
const SYNC_QUEUE_STORE = 'syncQueue';
|
const SYNC_QUEUE_STORE = 'syncQueue';
|
||||||
|
|
||||||
@@ -29,11 +29,18 @@ class LocalDB {
|
|||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
const oldVersion = event.oldVersion;
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(NOTES_STORE)) {
|
if (!db.objectStoreNames.contains(NOTES_STORE)) {
|
||||||
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
|
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
|
||||||
notesStore.createIndex('modified', 'modified', { unique: false });
|
notesStore.createIndex('modified', 'modified', { unique: false });
|
||||||
notesStore.createIndex('category', 'category', { unique: false });
|
notesStore.createIndex('category', 'category', { unique: false });
|
||||||
|
} else if (oldVersion < 2) {
|
||||||
|
// Clear notes store when upgrading to v2 to remove old cached notes
|
||||||
|
// with stripped first lines
|
||||||
|
const transaction = (event.target as IDBOpenDBRequest).transaction!;
|
||||||
|
const notesStore = transaction.objectStore(NOTES_STORE);
|
||||||
|
notesStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
|
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
import { NextcloudAPI } from '../api/nextcloud';
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
import { localDB, SyncOperation } from '../db/localDB';
|
import { localDB } from '../db/localDB';
|
||||||
|
|
||||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
|
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
|
||||||
|
|
||||||
@@ -9,12 +9,12 @@ export class SyncManager {
|
|||||||
private isOnline: boolean = navigator.onLine;
|
private isOnline: boolean = navigator.onLine;
|
||||||
private syncInProgress: boolean = false;
|
private syncInProgress: boolean = false;
|
||||||
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
||||||
|
private syncCompleteCallback: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
this.isOnline = true;
|
this.isOnline = true;
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
this.processSyncQueue();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
@@ -31,259 +31,253 @@ export class SyncManager {
|
|||||||
this.statusCallback = callback;
|
this.statusCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSyncCompleteCallback(callback: () => void) {
|
||||||
|
this.syncCompleteCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
private notifyStatus(status: SyncStatus, pendingCount: number) {
|
private notifyStatus(status: SyncStatus, pendingCount: number) {
|
||||||
if (this.statusCallback) {
|
if (this.statusCallback) {
|
||||||
this.statusCallback(status, pendingCount);
|
this.statusCallback(status, pendingCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPendingCount(): Promise<number> {
|
// Load notes: cache-first, then sync in background
|
||||||
const queue = await localDB.getSyncQueue();
|
|
||||||
return queue.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load notes from local DB first, then sync with server
|
|
||||||
async loadNotes(): Promise<Note[]> {
|
async loadNotes(): Promise<Note[]> {
|
||||||
const localNotes = await localDB.getAllNotes();
|
// Try to load from cache first (instant)
|
||||||
|
const cachedNotes = await localDB.getAllNotes();
|
||||||
|
|
||||||
|
// If we have cached notes and we're offline, return them
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
return cachedNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have cached notes, return them immediately
|
||||||
|
// Then sync in background
|
||||||
|
if (cachedNotes.length > 0) {
|
||||||
|
this.syncInBackground();
|
||||||
|
return cachedNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache - must fetch from server
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isOnline && this.api) {
|
|
||||||
try {
|
try {
|
||||||
await this.syncWithServer();
|
this.notifyStatus('syncing', 0);
|
||||||
return await localDB.getAllNotes();
|
const notes = await this.fetchAndCacheNotes();
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
return notes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to sync with server, using local data:', error);
|
this.notifyStatus('error', 0);
|
||||||
return localNotes;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return localNotes;
|
// Background sync: compare etags and only fetch changed content
|
||||||
}
|
private async syncInBackground(): Promise<void> {
|
||||||
|
if (!this.api || this.syncInProgress) return;
|
||||||
// Sync with server: fetch remote notes and merge with local
|
|
||||||
async syncWithServer(): Promise<void> {
|
|
||||||
if (!this.api || !this.isOnline || this.syncInProgress) return;
|
|
||||||
|
|
||||||
this.syncInProgress = true;
|
this.syncInProgress = true;
|
||||||
this.notifyStatus('syncing', await this.getPendingCount());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, process any pending operations
|
this.notifyStatus('syncing', 0);
|
||||||
await this.processSyncQueue();
|
|
||||||
|
|
||||||
// Fetch notes directly from WebDAV (file system)
|
// Get metadata for all notes (fast - no content)
|
||||||
const serverNotes = await this.api.fetchNotesWebDAV();
|
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
const localNotes = await localDB.getAllNotes();
|
const cachedNotes = await localDB.getAllNotes();
|
||||||
|
|
||||||
// Merge strategy: use file path as unique identifier
|
// Build maps for comparison
|
||||||
const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes);
|
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
||||||
|
const cachedMap = new Map(cachedNotes.map(n => [n.id, n]));
|
||||||
|
|
||||||
// Update local DB with merged notes (no clearNotes - safer!)
|
// Find notes that need content fetched (new or changed etag)
|
||||||
for (const note of mergedNotes) {
|
const notesToFetch: Note[] = [];
|
||||||
await localDB.saveNote(note);
|
for (const serverNote of serverNotes) {
|
||||||
|
const cached = cachedMap.get(serverNote.id);
|
||||||
|
if (!cached || cached.etag !== serverNote.etag) {
|
||||||
|
notesToFetch.push(serverNote);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove notes that no longer exist on server
|
// Fetch content for changed notes
|
||||||
const serverIds = new Set(serverNotes.map(n => n.id));
|
for (const note of notesToFetch) {
|
||||||
for (const localNote of localNotes) {
|
try {
|
||||||
if (!serverIds.has(localNote.id) && typeof localNote.id === 'string') {
|
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||||
await localDB.deleteNote(localNote.id);
|
await localDB.saveNote(fullNote);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deleted notes from cache
|
||||||
|
for (const cachedNote of cachedNotes) {
|
||||||
|
if (!serverMap.has(cachedNote.id)) {
|
||||||
|
await localDB.deleteNote(cachedNote.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Notify that sync is complete so UI can reload
|
||||||
|
if (this.syncCompleteCallback) {
|
||||||
|
this.syncCompleteCallback();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error);
|
console.error('Background sync failed:', error);
|
||||||
this.notifyStatus('error', await this.getPendingCount());
|
this.notifyStatus('error', 0);
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.syncInProgress = false;
|
this.syncInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeNotesWebDAV(localNotes: Note[], serverNotes: Note[]): Note[] {
|
// Fetch all notes and cache them
|
||||||
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
private async fetchAndCacheNotes(): Promise<Note[]> {
|
||||||
const localMap = new Map(localNotes.map(n => [n.id, n]));
|
|
||||||
const merged: Note[] = [];
|
|
||||||
|
|
||||||
// Add all server notes (they are the source of truth for existing files)
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add local-only notes (not yet synced to server)
|
|
||||||
localNotes.forEach(localNote => {
|
|
||||||
if (!serverMap.has(localNote.id)) {
|
|
||||||
merged.push(localNote);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create note (offline-first)
|
|
||||||
async createNote(title: string, content: string, category: string): Promise<Note> {
|
|
||||||
// Generate filename-based ID
|
|
||||||
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`;
|
|
||||||
const tempNote: Note = {
|
|
||||||
id: `${category}/${filename}`,
|
|
||||||
filename,
|
|
||||||
path: category ? `${category}/${filename}` : filename,
|
|
||||||
etag: '',
|
|
||||||
readonly: false,
|
|
||||||
content,
|
|
||||||
title,
|
|
||||||
category,
|
|
||||||
favorite: false,
|
|
||||||
modified: Math.floor(Date.now() / 1000),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save to local DB immediately
|
|
||||||
await localDB.saveNote(tempNote);
|
|
||||||
|
|
||||||
// Queue for sync
|
|
||||||
const operation: SyncOperation = {
|
|
||||||
id: `create-${tempNote.id}-${Date.now()}`,
|
|
||||||
type: 'create',
|
|
||||||
noteId: tempNote.id,
|
|
||||||
note: tempNote,
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempNote;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update note (offline-first)
|
|
||||||
async updateNote(note: Note): Promise<Note> {
|
|
||||||
// Update local DB immediately
|
|
||||||
await localDB.saveNote(note);
|
|
||||||
|
|
||||||
// Queue for sync
|
|
||||||
const operation: SyncOperation = {
|
|
||||||
id: `update-${note.id}-${Date.now()}`,
|
|
||||||
type: 'update',
|
|
||||||
noteId: note.id,
|
|
||||||
note,
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return note;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete note (offline-first)
|
|
||||||
async deleteNote(id: number | string): Promise<void> {
|
|
||||||
// Delete from local DB immediately
|
|
||||||
await localDB.deleteNote(id);
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process sync queue
|
|
||||||
async processSyncQueue(): Promise<void> {
|
|
||||||
if (!this.api || !this.isOnline || this.syncInProgress) return;
|
|
||||||
|
|
||||||
const queue = await localDB.getSyncQueue();
|
|
||||||
if (queue.length === 0) return;
|
|
||||||
|
|
||||||
this.syncInProgress = true;
|
|
||||||
this.notifyStatus('syncing', queue.length);
|
|
||||||
|
|
||||||
for (const operation of queue) {
|
|
||||||
try {
|
|
||||||
await this.processOperation(operation);
|
|
||||||
await localDB.removeFromSyncQueue(operation.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to process operation ${operation.id}:`, error);
|
|
||||||
|
|
||||||
// Increment retry count
|
|
||||||
operation.retryCount++;
|
|
||||||
if (operation.retryCount > 5) {
|
|
||||||
console.error(`Operation ${operation.id} failed after 5 retries, removing from queue`);
|
|
||||||
await localDB.removeFromSyncQueue(operation.id);
|
|
||||||
} else {
|
|
||||||
await localDB.addToSyncQueue(operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncInProgress = false;
|
|
||||||
const remainingCount = await this.getPendingCount();
|
|
||||||
this.notifyStatus(remainingCount > 0 ? 'error' : 'idle', remainingCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processOperation(operation: SyncOperation): Promise<void> {
|
|
||||||
if (!this.api) throw new Error('API not initialized');
|
if (!this.api) throw new Error('API not initialized');
|
||||||
|
|
||||||
switch (operation.type) {
|
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
case 'create':
|
const notesWithContent: Note[] = [];
|
||||||
if (operation.note) {
|
|
||||||
const serverNote = await this.api.createNoteWebDAV(
|
|
||||||
operation.note.title,
|
|
||||||
operation.note.content,
|
|
||||||
operation.note.category
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update local note with server response (etag, etc.)
|
for (const note of serverNotes) {
|
||||||
await localDB.saveNote(serverNote);
|
try {
|
||||||
|
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||||
|
notesWithContent.push(fullNote);
|
||||||
|
await localDB.saveNote(fullNote);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case 'update':
|
|
||||||
if (operation.note) {
|
|
||||||
const serverNote = await this.api.updateNoteWebDAV(operation.note);
|
|
||||||
await localDB.saveNote(serverNote);
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case 'delete':
|
return notesWithContent;
|
||||||
if (operation.noteId) {
|
}
|
||||||
// For delete, we need the note object to know the filename
|
|
||||||
const note = await localDB.getNote(operation.noteId);
|
// Fetch content for a specific note on-demand
|
||||||
if (note) {
|
async fetchNoteContent(note: Note): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
throw new Error('Cannot fetch note content while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||||
|
await localDB.saveNote(fullNote);
|
||||||
|
return fullNote;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create note on server and cache
|
||||||
|
async createNote(title: string, content: string, category: string): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot create note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const note = await this.api.createNoteWebDAV(title, content, category);
|
||||||
|
await localDB.saveNote(note);
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return note;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update note on server and cache
|
||||||
|
async updateNote(note: Note): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot update note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const updatedNote = await this.api.updateNoteWebDAV(note);
|
||||||
|
await localDB.saveNote(updatedNote);
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return updatedNote;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete note from server and cache
|
||||||
|
async deleteNote(note: Note): Promise<void> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot delete note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
await this.api.deleteNoteWebDAV(note);
|
await this.api.deleteNoteWebDAV(note);
|
||||||
|
await localDB.deleteNote(note.id);
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
// Move note to different category on server and cache
|
||||||
|
async moveNote(note: Note, newCategory: string): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot move note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
|
||||||
|
await localDB.deleteNote(note.id);
|
||||||
|
await localDB.saveNote(movedNote);
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return movedNote;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual sync with server
|
||||||
|
async syncWithServer(): Promise<void> {
|
||||||
|
if (!this.api || !this.isOnline || this.syncInProgress) return;
|
||||||
|
await this.syncInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
getOnlineStatus(): boolean {
|
getOnlineStatus(): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user