Merge dev: offline-first functionality (v0.1.3)

This commit is contained in:
drelich
2026-03-21 21:01:18 +01:00
8 changed files with 571 additions and 65 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}
/> />
</> </>
)} )}

View File

@@ -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;

View File

@@ -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
View 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();

View 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
View 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();