Merge dev: offline-first functionality (v0.1.3)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nextcloud-notes-tauri",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nextcloud Notes",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"identifier": "com.davidrelich.nextcloud-notes",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
150
src/App.tsx
150
src/App.tsx
@@ -5,6 +5,9 @@ import { NoteEditor } from './components/NoteEditor';
|
||||
import { CategoriesSidebar } from './components/CategoriesSidebar';
|
||||
import { NextcloudAPI } from './api/nextcloud';
|
||||
import { Note } from './types';
|
||||
import { syncManager, SyncStatus } from './services/syncManager';
|
||||
import { localDB } from './db/localDB';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
|
||||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
@@ -25,43 +28,60 @@ function App() {
|
||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||
const [previewFontSize, setPreviewFontSize] = useState(16);
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||
const isOnline = useOnlineStatus();
|
||||
|
||||
useEffect(() => {
|
||||
const savedServer = localStorage.getItem('serverURL');
|
||||
const savedUsername = localStorage.getItem('username');
|
||||
const savedPassword = localStorage.getItem('password');
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
|
||||
const savedEditorFont = localStorage.getItem('editorFont');
|
||||
const savedPreviewFont = localStorage.getItem('previewFont');
|
||||
const initApp = async () => {
|
||||
await localDB.init();
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
if (savedEditorFont) {
|
||||
setEditorFont(savedEditorFont);
|
||||
}
|
||||
if (savedPreviewFont) {
|
||||
setPreviewFont(savedPreviewFont);
|
||||
}
|
||||
const savedEditorFontSize = localStorage.getItem('editorFontSize');
|
||||
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
|
||||
if (savedEditorFontSize) {
|
||||
setEditorFontSize(parseInt(savedEditorFontSize, 10));
|
||||
}
|
||||
if (savedPreviewFontSize) {
|
||||
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
|
||||
}
|
||||
const savedServer = localStorage.getItem('serverURL');
|
||||
const savedUsername = localStorage.getItem('username');
|
||||
const savedPassword = localStorage.getItem('password');
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
|
||||
const savedEditorFont = localStorage.getItem('editorFont');
|
||||
const savedPreviewFont = localStorage.getItem('previewFont');
|
||||
|
||||
if (savedServer && savedUsername && savedPassword) {
|
||||
const apiInstance = new NextcloudAPI({
|
||||
serverURL: savedServer,
|
||||
username: savedUsername,
|
||||
password: savedPassword,
|
||||
});
|
||||
setApi(apiInstance);
|
||||
setUsername(savedUsername);
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
if (savedEditorFont) {
|
||||
setEditorFont(savedEditorFont);
|
||||
}
|
||||
if (savedPreviewFont) {
|
||||
setPreviewFont(savedPreviewFont);
|
||||
}
|
||||
const savedEditorFontSize = localStorage.getItem('editorFontSize');
|
||||
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
|
||||
if (savedEditorFontSize) {
|
||||
setEditorFontSize(parseInt(savedEditorFontSize, 10));
|
||||
}
|
||||
if (savedPreviewFontSize) {
|
||||
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
|
||||
}
|
||||
|
||||
if (savedServer && savedUsername && savedPassword) {
|
||||
const apiInstance = new NextcloudAPI({
|
||||
serverURL: savedServer,
|
||||
username: savedUsername,
|
||||
password: savedPassword,
|
||||
});
|
||||
setApi(apiInstance);
|
||||
syncManager.setAPI(apiInstance);
|
||||
setUsername(savedUsername);
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initApp();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,43 +108,62 @@ function App() {
|
||||
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
syncManager.setStatusCallback((status, count) => {
|
||||
setSyncStatus(status);
|
||||
setPendingSyncCount(count);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (api && isLoggedIn) {
|
||||
syncNotes();
|
||||
const interval = setInterval(syncNotes, 300000);
|
||||
loadNotes();
|
||||
const interval = setInterval(() => syncNotes(), 300000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [api, isLoggedIn]);
|
||||
|
||||
const syncNotes = async () => {
|
||||
if (!api) return;
|
||||
const loadNotes = async () => {
|
||||
try {
|
||||
const fetched = await api.fetchNotes();
|
||||
setNotes(fetched.sort((a, b) => b.modified - a.modified));
|
||||
if (!selectedNoteId && fetched.length > 0) {
|
||||
setSelectedNoteId(fetched[0].id);
|
||||
const loadedNotes = await syncManager.loadNotes();
|
||||
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
|
||||
if (!selectedNoteId && loadedNotes.length > 0) {
|
||||
setSelectedNoteId(loadedNotes[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNotes = async () => {
|
||||
try {
|
||||
await syncManager.syncWithServer();
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = (serverURL: string, username: string, password: string) => {
|
||||
const handleLogin = async (serverURL: string, username: string, password: string) => {
|
||||
localStorage.setItem('serverURL', serverURL);
|
||||
localStorage.setItem('username', username);
|
||||
localStorage.setItem('password', password);
|
||||
|
||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||
setApi(apiInstance);
|
||||
syncManager.setAPI(apiInstance);
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('serverURL');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('password');
|
||||
await localDB.clearNotes();
|
||||
await localDB.clearSyncQueue();
|
||||
setApi(null);
|
||||
syncManager.setAPI(null);
|
||||
setUsername('');
|
||||
setNotes([]);
|
||||
setSelectedNoteId(null);
|
||||
@@ -157,7 +196,6 @@ function App() {
|
||||
};
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
if (!api) return;
|
||||
try {
|
||||
const timestamp = new Date().toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -168,7 +206,7 @@ function App() {
|
||||
hour12: false,
|
||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
||||
|
||||
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||
const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||
setNotes([note, ...notes]);
|
||||
setSelectedNoteId(note.id);
|
||||
} catch (error) {
|
||||
@@ -183,28 +221,21 @@ function App() {
|
||||
};
|
||||
|
||||
const handleUpdateNote = async (updatedNote: Note) => {
|
||||
if (!api) return;
|
||||
try {
|
||||
console.log('Sending to API - content length:', updatedNote.content.length);
|
||||
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50));
|
||||
const result = await api.updateNote(updatedNote);
|
||||
console.log('Received from API - content length:', result.content.length);
|
||||
console.log('Received from API - last 50 chars:', result.content.slice(-50));
|
||||
// Update notes array with server response now that we have manual save
|
||||
setNotes(notes.map(n => n.id === result.id ? result : n));
|
||||
await syncManager.updateNote(updatedNote);
|
||||
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
|
||||
} catch (error) {
|
||||
console.error('Update note failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (note: Note) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
await api.deleteNote(note.id);
|
||||
setNotes(notes.filter(n => n.id !== note.id));
|
||||
await syncManager.deleteNote(note.id);
|
||||
const remainingNotes = notes.filter(n => n.id !== note.id);
|
||||
setNotes(remainingNotes);
|
||||
if (selectedNoteId === note.id) {
|
||||
setSelectedNoteId(notes[0]?.id || null);
|
||||
setSelectedNoteId(remainingNotes[0]?.id || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete note failed:', error);
|
||||
@@ -267,6 +298,9 @@ function App() {
|
||||
showFavoritesOnly={showFavoritesOnly}
|
||||
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
syncStatus={syncStatus}
|
||||
pendingSyncCount={pendingSyncCount}
|
||||
isOnline={isOnline}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const previousNoteIdRef = useRef<number | null>(null);
|
||||
const previousNoteContentRef = useRef<string>('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -131,12 +132,12 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
useEffect(() => {
|
||||
const loadNewNote = () => {
|
||||
if (note) {
|
||||
console.log(`[Note ${note.id}] Loading note. Title: "${note.title}", Content length: ${note.content.length}`);
|
||||
setLocalTitle(note.title);
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
setIsPreviewMode(false);
|
||||
setProcessedContent(''); // Clear preview content immediately
|
||||
|
||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
@@ -144,21 +145,29 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
||||
|
||||
previousNoteIdRef.current = note.id;
|
||||
previousNoteContentRef.current = note.content;
|
||||
}
|
||||
};
|
||||
|
||||
// Switching to a different note
|
||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
||||
// Clear preview content immediately when switching notes
|
||||
setProcessedContent('');
|
||||
if (hasUnsavedChanges) {
|
||||
handleSave();
|
||||
}
|
||||
loadNewNote();
|
||||
} else {
|
||||
}
|
||||
// Same note but content changed from server (and no unsaved local changes)
|
||||
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) {
|
||||
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
|
||||
loadNewNote();
|
||||
}
|
||||
}, [note?.id]);
|
||||
// Initial load
|
||||
else if (!note || previousNoteIdRef.current === null) {
|
||||
loadNewNote();
|
||||
}
|
||||
}, [note?.id, note?.content, note?.modified]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!note || !hasUnsavedChanges) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Note } from '../types';
|
||||
import { SyncStatus } from '../services/syncManager';
|
||||
|
||||
interface NotesListProps {
|
||||
notes: Note[];
|
||||
@@ -13,6 +14,9 @@ interface NotesListProps {
|
||||
showFavoritesOnly: boolean;
|
||||
onToggleFavorites: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
syncStatus: SyncStatus;
|
||||
pendingSyncCount: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export function NotesList({
|
||||
@@ -27,6 +31,9 @@ export function NotesList({
|
||||
showFavoritesOnly,
|
||||
onToggleFavorites,
|
||||
hasUnsavedChanges,
|
||||
syncStatus: _syncStatus,
|
||||
pendingSyncCount,
|
||||
isOnline,
|
||||
}: NotesListProps) {
|
||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
||||
@@ -117,7 +124,22 @@ export function NotesList({
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||
{!isOnline && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3" />
|
||||
</svg>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
{pendingSyncCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
{pendingSyncCount} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
|
||||
148
src/db/localDB.ts
Normal file
148
src/db/localDB.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Note } from '../types';
|
||||
|
||||
const DB_NAME = 'nextcloud-notes-db';
|
||||
const DB_VERSION = 1;
|
||||
const NOTES_STORE = 'notes';
|
||||
const SYNC_QUEUE_STORE = 'syncQueue';
|
||||
|
||||
export interface SyncOperation {
|
||||
id: string;
|
||||
type: 'create' | 'update' | 'delete';
|
||||
noteId: number | string;
|
||||
note?: Note;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
class LocalDB {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(NOTES_STORE)) {
|
||||
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
|
||||
notesStore.createIndex('modified', 'modified', { unique: false });
|
||||
notesStore.createIndex('category', 'category', { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
|
||||
db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
const transaction = this.db.transaction(storeName, mode);
|
||||
return transaction.objectStore(storeName);
|
||||
}
|
||||
|
||||
// Notes operations
|
||||
async getAllNotes(): Promise<Note[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getNote(id: number): Promise<Note | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNote(note: Note): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.put(note);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNotes(notes: Note[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const transaction = store.transaction;
|
||||
|
||||
notes.forEach(note => store.put(note));
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNote(id: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearNotes(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync queue operations
|
||||
async addToSyncQueue(operation: SyncOperation): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.put(operation);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncQueue(): Promise<SyncOperation[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromSyncQueue(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncQueue(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localDB = new LocalDB();
|
||||
20
src/hooks/useOnlineStatus.ts
Normal file
20
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
273
src/services/syncManager.ts
Normal file
273
src/services/syncManager.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Note } from '../types';
|
||||
import { NextcloudAPI } from '../api/nextcloud';
|
||||
import { localDB, SyncOperation } from '../db/localDB';
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
|
||||
|
||||
export class SyncManager {
|
||||
private api: NextcloudAPI | null = null;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
private syncInProgress: boolean = false;
|
||||
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.notifyStatus('idle', 0);
|
||||
this.processSyncQueue();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.notifyStatus('offline', 0);
|
||||
});
|
||||
}
|
||||
|
||||
setAPI(api: NextcloudAPI | null) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
setStatusCallback(callback: (status: SyncStatus, pendingCount: number) => void) {
|
||||
this.statusCallback = callback;
|
||||
}
|
||||
|
||||
private notifyStatus(status: SyncStatus, pendingCount: number) {
|
||||
if (this.statusCallback) {
|
||||
this.statusCallback(status, pendingCount);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPendingCount(): Promise<number> {
|
||||
const queue = await localDB.getSyncQueue();
|
||||
return queue.length;
|
||||
}
|
||||
|
||||
// Load notes from local DB first, then sync with server
|
||||
async loadNotes(): Promise<Note[]> {
|
||||
const localNotes = await localDB.getAllNotes();
|
||||
|
||||
if (this.isOnline && this.api) {
|
||||
try {
|
||||
await this.syncWithServer();
|
||||
return await localDB.getAllNotes();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync with server, using local data:', error);
|
||||
return localNotes;
|
||||
}
|
||||
}
|
||||
|
||||
return localNotes;
|
||||
}
|
||||
|
||||
// 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.notifyStatus('syncing', await this.getPendingCount());
|
||||
|
||||
try {
|
||||
// First, process any pending operations
|
||||
await this.processSyncQueue();
|
||||
|
||||
// Then fetch latest from server
|
||||
const serverNotes = await this.api.fetchNotes();
|
||||
const localNotes = await localDB.getAllNotes();
|
||||
|
||||
// Merge strategy: server wins for conflicts (last-write-wins based on modified timestamp)
|
||||
const mergedNotes = this.mergeNotes(localNotes, serverNotes);
|
||||
|
||||
// Save merged notes to local DB
|
||||
await localDB.clearNotes();
|
||||
await localDB.saveNotes(mergedNotes);
|
||||
|
||||
this.notifyStatus('idle', 0);
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
this.notifyStatus('error', await this.getPendingCount());
|
||||
throw error;
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private mergeNotes(localNotes: Note[], serverNotes: Note[]): Note[] {
|
||||
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
||||
const merged: Note[] = [];
|
||||
|
||||
// Add all server notes (they are the source of truth)
|
||||
serverNotes.forEach(serverNote => {
|
||||
merged.push(serverNote);
|
||||
});
|
||||
|
||||
// Add local-only notes (not yet synced, likely have temporary IDs)
|
||||
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> {
|
||||
// Create temporary note with negative ID for offline mode
|
||||
const tempNote: Note = {
|
||||
id: -Date.now(), // Temporary negative ID
|
||||
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): 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);
|
||||
|
||||
// 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');
|
||||
|
||||
switch (operation.type) {
|
||||
case 'create':
|
||||
if (operation.note) {
|
||||
const serverNote = await this.api.createNote(
|
||||
operation.note.title,
|
||||
operation.note.content,
|
||||
operation.note.category
|
||||
);
|
||||
|
||||
// Replace temporary note with server note
|
||||
await localDB.deleteNote(operation.note.id);
|
||||
await localDB.saveNote(serverNote);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
if (operation.note && operation.note.id > 0) {
|
||||
const serverNote = await this.api.updateNote(operation.note);
|
||||
await localDB.saveNote(serverNote);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
if (typeof operation.noteId === 'number' && operation.noteId > 0) {
|
||||
await this.api.deleteNote(operation.noteId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getOnlineStatus(): boolean {
|
||||
return this.isOnline;
|
||||
}
|
||||
}
|
||||
|
||||
export const syncManager = new SyncManager();
|
||||
Reference in New Issue
Block a user