Merge dev: offline-first functionality (v0.1.3)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nextcloud-notes-tauri",
|
"name": "nextcloud-notes-tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"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.2",
|
"version": "0.1.3",
|
||||||
"identifier": "com.davidrelich.nextcloud-notes",
|
"identifier": "com.davidrelich.nextcloud-notes",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"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 { CategoriesSidebar } from './components/CategoriesSidebar';
|
||||||
import { NextcloudAPI } from './api/nextcloud';
|
import { NextcloudAPI } from './api/nextcloud';
|
||||||
import { Note } from './types';
|
import { Note } from './types';
|
||||||
|
import { syncManager, SyncStatus } from './services/syncManager';
|
||||||
|
import { localDB } from './db/localDB';
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -25,43 +28,60 @@ function App() {
|
|||||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||||
const [previewFontSize, setPreviewFontSize] = useState(16);
|
const [previewFontSize, setPreviewFontSize] = useState(16);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||||
|
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
|
const isOnline = useOnlineStatus();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedServer = localStorage.getItem('serverURL');
|
const initApp = async () => {
|
||||||
const savedUsername = localStorage.getItem('username');
|
await localDB.init();
|
||||||
const savedPassword = localStorage.getItem('password');
|
|
||||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
|
const savedServer = localStorage.getItem('serverURL');
|
||||||
const savedEditorFont = localStorage.getItem('editorFont');
|
const savedUsername = localStorage.getItem('username');
|
||||||
const savedPreviewFont = localStorage.getItem('previewFont');
|
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 (savedTheme) {
|
if (savedTheme) {
|
||||||
setTheme(savedTheme);
|
setTheme(savedTheme);
|
||||||
}
|
}
|
||||||
if (savedEditorFont) {
|
if (savedEditorFont) {
|
||||||
setEditorFont(savedEditorFont);
|
setEditorFont(savedEditorFont);
|
||||||
}
|
}
|
||||||
if (savedPreviewFont) {
|
if (savedPreviewFont) {
|
||||||
setPreviewFont(savedPreviewFont);
|
setPreviewFont(savedPreviewFont);
|
||||||
}
|
}
|
||||||
const savedEditorFontSize = localStorage.getItem('editorFontSize');
|
const savedEditorFontSize = localStorage.getItem('editorFontSize');
|
||||||
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
|
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
|
||||||
if (savedEditorFontSize) {
|
if (savedEditorFontSize) {
|
||||||
setEditorFontSize(parseInt(savedEditorFontSize, 10));
|
setEditorFontSize(parseInt(savedEditorFontSize, 10));
|
||||||
}
|
}
|
||||||
if (savedPreviewFontSize) {
|
if (savedPreviewFontSize) {
|
||||||
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
|
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedServer && savedUsername && savedPassword) {
|
if (savedServer && savedUsername && savedPassword) {
|
||||||
const apiInstance = new NextcloudAPI({
|
const apiInstance = new NextcloudAPI({
|
||||||
serverURL: savedServer,
|
serverURL: savedServer,
|
||||||
username: savedUsername,
|
username: savedUsername,
|
||||||
password: savedPassword,
|
password: savedPassword,
|
||||||
});
|
});
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
setUsername(savedUsername);
|
syncManager.setAPI(apiInstance);
|
||||||
setIsLoggedIn(true);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -88,43 +108,62 @@ function App() {
|
|||||||
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||||
}, [effectiveTheme]);
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncManager.setStatusCallback((status, count) => {
|
||||||
|
setSyncStatus(status);
|
||||||
|
setPendingSyncCount(count);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (api && isLoggedIn) {
|
if (api && isLoggedIn) {
|
||||||
syncNotes();
|
loadNotes();
|
||||||
const interval = setInterval(syncNotes, 300000);
|
const interval = setInterval(() => syncNotes(), 300000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [api, isLoggedIn]);
|
}, [api, isLoggedIn]);
|
||||||
|
|
||||||
const syncNotes = async () => {
|
const loadNotes = async () => {
|
||||||
if (!api) return;
|
|
||||||
try {
|
try {
|
||||||
const fetched = await api.fetchNotes();
|
const loadedNotes = await syncManager.loadNotes();
|
||||||
setNotes(fetched.sort((a, b) => b.modified - a.modified));
|
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
|
||||||
if (!selectedNoteId && fetched.length > 0) {
|
if (!selectedNoteId && loadedNotes.length > 0) {
|
||||||
setSelectedNoteId(fetched[0].id);
|
setSelectedNoteId(loadedNotes[0].id);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load notes:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncNotes = async () => {
|
||||||
|
try {
|
||||||
|
await syncManager.syncWithServer();
|
||||||
|
await loadNotes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', 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('serverURL', serverURL);
|
||||||
localStorage.setItem('username', username);
|
localStorage.setItem('username', username);
|
||||||
localStorage.setItem('password', password);
|
localStorage.setItem('password', password);
|
||||||
|
|
||||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
syncManager.setAPI(apiInstance);
|
||||||
setUsername(username);
|
setUsername(username);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
localStorage.removeItem('serverURL');
|
localStorage.removeItem('serverURL');
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('password');
|
localStorage.removeItem('password');
|
||||||
|
await localDB.clearNotes();
|
||||||
|
await localDB.clearSyncQueue();
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
syncManager.setAPI(null);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
setSelectedNoteId(null);
|
setSelectedNoteId(null);
|
||||||
@@ -157,7 +196,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleCreateNote = async () => {
|
||||||
if (!api) return;
|
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toLocaleString('en-US', {
|
const timestamp = new Date().toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -168,7 +206,7 @@ function App() {
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
||||||
|
|
||||||
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||||
setNotes([note, ...notes]);
|
setNotes([note, ...notes]);
|
||||||
setSelectedNoteId(note.id);
|
setSelectedNoteId(note.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,28 +221,21 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateNote = async (updatedNote: Note) => {
|
const handleUpdateNote = async (updatedNote: Note) => {
|
||||||
if (!api) return;
|
|
||||||
try {
|
try {
|
||||||
console.log('Sending to API - content length:', updatedNote.content.length);
|
await syncManager.updateNote(updatedNote);
|
||||||
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50));
|
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update note failed:', error);
|
console.error('Update note failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNote = async (note: Note) => {
|
const handleDeleteNote = async (note: Note) => {
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteNote(note.id);
|
await syncManager.deleteNote(note.id);
|
||||||
setNotes(notes.filter(n => n.id !== note.id));
|
const remainingNotes = notes.filter(n => n.id !== note.id);
|
||||||
|
setNotes(remainingNotes);
|
||||||
if (selectedNoteId === note.id) {
|
if (selectedNoteId === note.id) {
|
||||||
setSelectedNoteId(notes[0]?.id || null);
|
setSelectedNoteId(remainingNotes[0]?.id || null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete note failed:', error);
|
console.error('Delete note failed:', error);
|
||||||
@@ -267,6 +298,9 @@ function App() {
|
|||||||
showFavoritesOnly={showFavoritesOnly}
|
showFavoritesOnly={showFavoritesOnly}
|
||||||
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
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 [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const previousNoteIdRef = useRef<number | null>(null);
|
const previousNoteIdRef = useRef<number | null>(null);
|
||||||
|
const previousNoteContentRef = useRef<string>('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -131,12 +132,12 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadNewNote = () => {
|
const loadNewNote = () => {
|
||||||
if (note) {
|
if (note) {
|
||||||
console.log(`[Note ${note.id}] Loading note. Title: "${note.title}", Content length: ${note.content.length}`);
|
|
||||||
setLocalTitle(note.title);
|
setLocalTitle(note.title);
|
||||||
setLocalContent(note.content);
|
setLocalContent(note.content);
|
||||||
setLocalCategory(note.category || '');
|
setLocalCategory(note.category || '');
|
||||||
setLocalFavorite(note.favorite);
|
setLocalFavorite(note.favorite);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
setIsPreviewMode(false);
|
||||||
setProcessedContent(''); // Clear preview content immediately
|
setProcessedContent(''); // Clear preview content immediately
|
||||||
|
|
||||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
@@ -144,21 +145,29 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
setTitleManuallyEdited(!titleMatchesFirstLine);
|
||||||
|
|
||||||
previousNoteIdRef.current = note.id;
|
previousNoteIdRef.current = note.id;
|
||||||
|
previousNoteContentRef.current = note.content;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Switching to a different note
|
||||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
||||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
||||||
// Clear preview content immediately when switching notes
|
|
||||||
setProcessedContent('');
|
setProcessedContent('');
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
handleSave();
|
handleSave();
|
||||||
}
|
}
|
||||||
loadNewNote();
|
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();
|
loadNewNote();
|
||||||
}
|
}
|
||||||
}, [note?.id]);
|
// Initial load
|
||||||
|
else if (!note || previousNoteIdRef.current === null) {
|
||||||
|
loadNewNote();
|
||||||
|
}
|
||||||
|
}, [note?.id, note?.content, note?.modified]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!note || !hasUnsavedChanges) return;
|
if (!note || !hasUnsavedChanges) return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
|
import { SyncStatus } from '../services/syncManager';
|
||||||
|
|
||||||
interface NotesListProps {
|
interface NotesListProps {
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
@@ -13,6 +14,9 @@ interface NotesListProps {
|
|||||||
showFavoritesOnly: boolean;
|
showFavoritesOnly: boolean;
|
||||||
onToggleFavorites: () => void;
|
onToggleFavorites: () => void;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
syncStatus: SyncStatus;
|
||||||
|
pendingSyncCount: number;
|
||||||
|
isOnline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesList({
|
export function NotesList({
|
||||||
@@ -27,6 +31,9 @@ export function NotesList({
|
|||||||
showFavoritesOnly,
|
showFavoritesOnly,
|
||||||
onToggleFavorites,
|
onToggleFavorites,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
|
syncStatus: _syncStatus,
|
||||||
|
pendingSyncCount,
|
||||||
|
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 | null>(null);
|
||||||
@@ -117,7 +124,22 @@ export function NotesList({
|
|||||||
>
|
>
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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">
|
<div className="flex items-center space-x-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
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