Implement synchronization, offline data handling, and intelligent request routing
- Add services for offline data management, including `offline-data.service.ts`, ensuring data is saved to a local database and synced with the server when online. - Introduce bidirectional `SyncService` for managing data synchronization with conflict resolution and retry mechanisms. - Create `data.service.ts` to handle smart routing between local database and server API based on connectivity status. - Update models and logic to support comprehensive synchronization for books, chapters, characters, and conversations. - Implement event listeners for online/offline detection and automatic sync scheduling.
This commit is contained in:
58
lib/db/sync.service.ts
Normal file
58
lib/db/sync.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Sync progress interface
|
||||
*/
|
||||
export interface SyncProgress {
|
||||
isSyncing: boolean;
|
||||
pendingChanges: number;
|
||||
isOnline: boolean;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status from local database
|
||||
*/
|
||||
export async function getSyncStatus(): Promise<SyncProgress> {
|
||||
if (!window.electron) {
|
||||
return {
|
||||
isSyncing: false,
|
||||
pendingChanges: 0,
|
||||
isOnline: navigator.onLine
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.dbGetSyncStatus();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get sync status:', error);
|
||||
return {
|
||||
isSyncing: false,
|
||||
pendingChanges: 0,
|
||||
isOnline: navigator.onLine,
|
||||
lastError: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes to sync
|
||||
*/
|
||||
export async function getPendingChanges(limit: number = 100) {
|
||||
if (!window.electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.dbGetPendingChanges(limit);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get pending changes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
366
lib/services/data.service.ts
Normal file
366
lib/services/data.service.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import System from '@/lib/models/System';
|
||||
import { BookProps, BookListProps } from '@/lib/models/Book';
|
||||
import { ChapterProps } from '@/lib/models/Chapter';
|
||||
import { CharacterProps } from '@/lib/models/Character';
|
||||
import { Conversation } from '@/lib/models/QuillSense';
|
||||
|
||||
/**
|
||||
* DataService - Smart routing layer between server API and local database
|
||||
* Automatically routes requests based on offline/online status
|
||||
*/
|
||||
export class DataService {
|
||||
private static isOffline: boolean = false;
|
||||
private static accessToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Set offline mode status
|
||||
*/
|
||||
static setOfflineMode(offline: boolean): void {
|
||||
this.isOffline = offline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set access token for API requests
|
||||
*/
|
||||
static setAccessToken(token: string | null): void {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently offline
|
||||
*/
|
||||
static isCurrentlyOffline(): boolean {
|
||||
return this.isOffline;
|
||||
}
|
||||
|
||||
// ========== BOOK OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all books
|
||||
*/
|
||||
static async getBooks(): Promise<BookListProps[]> {
|
||||
if (this.isOffline) {
|
||||
// Use local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetBooks();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to get books from local DB');
|
||||
}
|
||||
return result.data || [];
|
||||
} else {
|
||||
// Use server API
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await System.authGetQueryToServer<BookListProps[]>(
|
||||
'books',
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
return response.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single book with all data
|
||||
*/
|
||||
static async getBook(bookId: string): Promise<BookProps | null> {
|
||||
if (this.isOffline) {
|
||||
// Use local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetBook(bookId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to get book from local DB');
|
||||
}
|
||||
return result.data || null;
|
||||
} else {
|
||||
// Use server API
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await System.authGetQueryToServer<BookProps>(
|
||||
`books/${bookId}`,
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
return response.data || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a book
|
||||
*/
|
||||
static async saveBook(book: BookProps | BookListProps, authorId?: string): Promise<void> {
|
||||
if (this.isOffline) {
|
||||
// Save to local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbSaveBook(book, authorId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save book to local DB');
|
||||
}
|
||||
} else {
|
||||
// Save to server
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const isUpdate = 'bookId' in book && book.bookId;
|
||||
if (isUpdate) {
|
||||
await System.authPutToServer(`books/${book.bookId || (book as any).id}`, book, this.accessToken);
|
||||
} else {
|
||||
await System.authPostToServer('books', book, this.accessToken);
|
||||
}
|
||||
|
||||
// Also save to local DB for caching
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
await (window as any).electron.dbSaveBook(book, authorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a book
|
||||
*/
|
||||
static async deleteBook(bookId: string): Promise<void> {
|
||||
if (this.isOffline) {
|
||||
// Delete from local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbDeleteBook(bookId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete book from local DB');
|
||||
}
|
||||
} else {
|
||||
// Delete from server
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
await System.authDeleteToServer(`books/${bookId}`, {}, this.accessToken);
|
||||
|
||||
// Also delete from local DB
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
await (window as any).electron.dbDeleteBook(bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== CHAPTER OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Save or update a chapter
|
||||
*/
|
||||
static async saveChapter(chapter: ChapterProps, bookId: string, contentId?: string): Promise<void> {
|
||||
if (this.isOffline) {
|
||||
// Save to local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbSaveChapter(chapter, bookId, contentId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save chapter to local DB');
|
||||
}
|
||||
} else {
|
||||
// Save to server
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const isUpdate = !!chapter.chapterId;
|
||||
if (isUpdate) {
|
||||
await System.authPutToServer(`chapters/${chapter.chapterId}`, chapter, this.accessToken);
|
||||
} else {
|
||||
await System.authPostToServer('chapters', { ...chapter, bookId }, this.accessToken);
|
||||
}
|
||||
|
||||
// Also save to local DB for caching
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
await (window as any).electron.dbSaveChapter(chapter, bookId, contentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== CHARACTER OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all characters for a book
|
||||
*/
|
||||
static async getCharacters(bookId: string): Promise<CharacterProps[]> {
|
||||
if (this.isOffline) {
|
||||
// Use local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetCharacters(bookId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to get characters from local DB');
|
||||
}
|
||||
return result.data || [];
|
||||
} else {
|
||||
// Use server API
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await System.authGetQueryToServer<CharacterProps[]>(
|
||||
`characters?bookId=${bookId}`,
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
return response.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a character
|
||||
*/
|
||||
static async saveCharacter(character: CharacterProps, bookId: string): Promise<void> {
|
||||
if (this.isOffline) {
|
||||
// Save to local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbSaveCharacter(character, bookId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save character to local DB');
|
||||
}
|
||||
} else {
|
||||
// Save to server
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const isUpdate = !!character.id;
|
||||
if (isUpdate) {
|
||||
await System.authPutToServer(`characters/${character.id}`, character, this.accessToken);
|
||||
} else {
|
||||
await System.authPostToServer('characters', { ...character, bookId }, this.accessToken);
|
||||
}
|
||||
|
||||
// Also save to local DB for caching
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
await (window as any).electron.dbSaveCharacter(character, bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== AI CONVERSATION OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all AI conversations for a book
|
||||
*/
|
||||
static async getConversations(bookId: string): Promise<Conversation[]> {
|
||||
if (this.isOffline) {
|
||||
// Use local database
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetConversations(bookId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to get conversations from local DB');
|
||||
}
|
||||
return result.data || [];
|
||||
} else {
|
||||
// Use server API
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await System.authGetQueryToServer<Conversation[]>(
|
||||
`ai/conversations?bookId=${bookId}`,
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
return response.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an AI conversation (always saves locally when using AI)
|
||||
*/
|
||||
static async saveConversation(conversation: Conversation, bookId: string): Promise<void> {
|
||||
// Always save AI conversations to local DB first
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
const result = await (window as any).electron.dbSaveConversation(conversation, bookId);
|
||||
if (!result.success) {
|
||||
console.error('Failed to save conversation to local DB:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// If online, also sync to server
|
||||
if (!this.isOffline && this.accessToken) {
|
||||
try {
|
||||
const isUpdate = !!conversation.id;
|
||||
if (isUpdate) {
|
||||
await System.authPutToServer(
|
||||
`ai/conversations/${conversation.id}`,
|
||||
conversation,
|
||||
this.accessToken
|
||||
);
|
||||
} else {
|
||||
await System.authPostToServer(
|
||||
'ai/conversations',
|
||||
{ ...conversation, bookId },
|
||||
this.accessToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync conversation to server:', error);
|
||||
// Don't throw - local save succeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SYNC STATUS ==========
|
||||
|
||||
/**
|
||||
* Get sync status from local database
|
||||
*/
|
||||
static async getSyncStatus(): Promise<any[]> {
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetSyncStatus();
|
||||
if (!result.success) {
|
||||
console.error('Failed to get sync status:', result.error);
|
||||
return [];
|
||||
}
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes awaiting sync
|
||||
*/
|
||||
static async getPendingChanges(limit: number = 100): Promise<any[]> {
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbGetPendingChanges(limit);
|
||||
if (!result.success) {
|
||||
console.error('Failed to get pending changes:', result.error);
|
||||
return [];
|
||||
}
|
||||
return result.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
export default DataService;
|
||||
227
lib/services/offline-data.service.ts
Normal file
227
lib/services/offline-data.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { BookProps, BookListProps } from '@/lib/models/Book';
|
||||
import { CharacterProps } from '@/lib/models/Character';
|
||||
import { Conversation } from '@/lib/models/QuillSense';
|
||||
import { WorldProps } from '@/lib/models/World';
|
||||
|
||||
/**
|
||||
* Service pour gérer les données avec cache local
|
||||
* Sauvegarde automatiquement dans la DB locale (Electron) quand disponible
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all books - from local DB if offline, from server otherwise
|
||||
*/
|
||||
export async function getBooks(fetchFromServer: () => Promise<BookListProps[]>): Promise<BookListProps[]> {
|
||||
if (!window.electron) {
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
// TODO: Check if offline mode is enabled
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
// Fetch from local DB
|
||||
const result = await window.electron.dbGetBooks();
|
||||
if (result.success) {
|
||||
return result.data || [];
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get books from local DB');
|
||||
} else {
|
||||
// Fetch from server and save to local DB
|
||||
const books = await fetchFromServer();
|
||||
|
||||
// Save to local DB in background
|
||||
for (const book of books) {
|
||||
try {
|
||||
await window.electron.dbSaveBook(book);
|
||||
} catch (error) {
|
||||
console.error('Failed to save book to local DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return books;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single book - from local DB if offline, from server otherwise
|
||||
*/
|
||||
export async function getBook(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<BookProps>
|
||||
): Promise<BookProps> {
|
||||
if (!window.electron) {
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
const result = await window.electron.dbGetBook(bookId);
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Book not found in local DB');
|
||||
} else {
|
||||
const book = await fetchFromServer();
|
||||
|
||||
// Save to local DB
|
||||
try {
|
||||
await window.electron.dbSaveBook(book);
|
||||
} catch (error) {
|
||||
console.error('Failed to save book to local DB:', error);
|
||||
}
|
||||
|
||||
return book;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save book - save to local DB and sync to server later if offline
|
||||
*/
|
||||
export async function saveBook(
|
||||
book: BookProps,
|
||||
authorId: string | undefined,
|
||||
saveToServer: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveBook(book, authorId);
|
||||
|
||||
if (!isOffline) {
|
||||
// Also save to server
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
// Data is already in local DB, will be synced later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get characters for a book
|
||||
*/
|
||||
export async function getCharacters(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<CharacterProps[]>
|
||||
): Promise<CharacterProps[]> {
|
||||
if (!window.electron) {
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
const result = await window.electron.dbGetCharacters(bookId);
|
||||
if (result.success) {
|
||||
return result.data || [];
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get characters from local DB');
|
||||
} else {
|
||||
const characters = await fetchFromServer();
|
||||
|
||||
// Save to local DB
|
||||
for (const character of characters) {
|
||||
try {
|
||||
await window.electron.dbSaveCharacter(character, bookId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save character to local DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return characters;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save character
|
||||
*/
|
||||
export async function saveCharacter(
|
||||
character: CharacterProps,
|
||||
bookId: string,
|
||||
saveToServer: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveCharacter(character, bookId);
|
||||
|
||||
if (!isOffline) {
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversations for a book
|
||||
*/
|
||||
export async function getConversations(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<Conversation[]>
|
||||
): Promise<Conversation[]> {
|
||||
if (!window.electron) {
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
const result = await window.electron.dbGetConversations(bookId);
|
||||
if (result.success) {
|
||||
return result.data || [];
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get conversations from local DB');
|
||||
} else {
|
||||
const conversations = await fetchFromServer();
|
||||
|
||||
// Save to local DB
|
||||
for (const conversation of conversations) {
|
||||
try {
|
||||
await window.electron.dbSaveConversation(conversation, bookId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save conversation to local DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversation
|
||||
*/
|
||||
export async function saveConversation(
|
||||
conversation: Conversation,
|
||||
bookId: string,
|
||||
saveToServer: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveConversation(conversation, bookId);
|
||||
|
||||
if (!isOffline) {
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
397
lib/services/sync.service.ts
Normal file
397
lib/services/sync.service.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import System from '@/lib/models/System';
|
||||
|
||||
/**
|
||||
* SyncService - Handles bidirectional synchronization between local DB and server
|
||||
* Implements conflict resolution and retry logic
|
||||
*/
|
||||
export class SyncService {
|
||||
private syncInterval: NodeJS.Timeout | null = null;
|
||||
private isSyncing: boolean = false;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
private accessToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Listen to online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.onlineStatusChanged(true);
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.onlineStatusChanged(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic sync every interval
|
||||
* @param intervalMs - Sync interval in milliseconds (default 30 seconds)
|
||||
*/
|
||||
startAutoSync(intervalMs: number = 30000): void {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
}
|
||||
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (this.isOnline && !this.isSyncing) {
|
||||
this.sync();
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`Auto-sync started with interval: ${intervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic sync
|
||||
*/
|
||||
stopAutoSync(): void {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
this.syncInterval = null;
|
||||
}
|
||||
console.log('Auto-sync stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set access token for API requests
|
||||
*/
|
||||
setAccessToken(token: string): void {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently online
|
||||
*/
|
||||
getOnlineStatus(): boolean {
|
||||
return this.isOnline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force set online/offline status (for manual toggle)
|
||||
*/
|
||||
setOnlineStatus(online: boolean): void {
|
||||
this.isOnline = online;
|
||||
this.onlineStatusChanged(online);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle online/offline status change
|
||||
*/
|
||||
private onlineStatusChanged(online: boolean): void {
|
||||
console.log(`Network status changed: ${online ? 'ONLINE' : 'OFFLINE'}`);
|
||||
|
||||
if (online && !this.isSyncing) {
|
||||
// When going online, trigger immediate sync
|
||||
setTimeout(() => this.sync(), 1000);
|
||||
}
|
||||
|
||||
// Notify listeners (will be implemented in OfflineContext)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('offline-status-changed', { detail: { online } }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full bidirectional sync
|
||||
*/
|
||||
async sync(): Promise<SyncResult> {
|
||||
if (!this.isOnline) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot sync while offline',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Sync already in progress',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
console.log('Starting sync...');
|
||||
|
||||
try {
|
||||
// Check Electron API availability
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
// Step 1: Push local changes to server
|
||||
const pushedChanges = await this.pushChanges();
|
||||
|
||||
// Step 2: Pull server changes to local
|
||||
const pulledChanges = await this.pullChanges();
|
||||
|
||||
console.log(`Sync completed: pushed ${pushedChanges}, pulled ${pulledChanges} changes`);
|
||||
|
||||
// Dispatch sync completion event
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||||
detail: { pushedChanges, pulledChanges }
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pushedChanges,
|
||||
pulledChanges
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
private async pushChanges(): Promise<number> {
|
||||
if (!this.accessToken) {
|
||||
console.warn('No access token available for sync');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get pending changes via Electron IPC
|
||||
const result = await (window as any).electron.dbGetPendingChanges(50);
|
||||
if (!result.success) {
|
||||
console.error('Failed to get pending changes:', result.error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pendingChanges = result.data || [];
|
||||
|
||||
if (pendingChanges.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Pushing ${pendingChanges.length} pending changes...`);
|
||||
|
||||
let successCount = 0;
|
||||
const syncedIds: number[] = [];
|
||||
|
||||
for (const change of pendingChanges) {
|
||||
try {
|
||||
const success = await this.pushSingleChange(change);
|
||||
if (success) {
|
||||
successCount++;
|
||||
syncedIds.push(change.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to push change ${change.id}:`, error);
|
||||
// Continue with next change
|
||||
}
|
||||
}
|
||||
|
||||
// Mark successfully synced changes via IPC
|
||||
if (syncedIds.length > 0) {
|
||||
// TODO: Add IPC handler for marking synced
|
||||
console.log('Synced changes:', syncedIds);
|
||||
}
|
||||
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a single change to server
|
||||
*/
|
||||
private async pushSingleChange(change: any): Promise<boolean> {
|
||||
if (!this.accessToken) return false;
|
||||
|
||||
const { table_name, operation, record_id, data } = change;
|
||||
let url = '';
|
||||
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
|
||||
|
||||
// Map table names to API endpoints
|
||||
switch (table_name) {
|
||||
case 'erit_books':
|
||||
url = operation === 'DELETE' ? `books/${record_id}` : 'books';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'book_chapters':
|
||||
url = operation === 'DELETE' ? `chapters/${record_id}` : 'chapters';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'book_characters':
|
||||
url = operation === 'DELETE' ? `characters/${record_id}` : 'characters';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'ai_conversations':
|
||||
url = operation === 'DELETE' ? `ai/conversations/${record_id}` : 'ai/conversations';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown table for sync: ${table_name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (method === 'DELETE') {
|
||||
await System.authDeleteToServer(url, {}, this.accessToken);
|
||||
} else if (method === 'PUT') {
|
||||
await System.authPutToServer(url, JSON.parse(data), this.accessToken);
|
||||
} else {
|
||||
await System.authPostToServer(url, JSON.parse(data), this.accessToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${table_name} ${operation}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
private async pullChanges(): Promise<number> {
|
||||
if (!this.accessToken) {
|
||||
console.warn('No access token available for sync');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get sync status via Electron IPC
|
||||
const statusResult = await (window as any).electron.dbGetSyncStatus();
|
||||
if (!statusResult.success) {
|
||||
console.error('Failed to get sync status:', statusResult.error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const syncStatus = statusResult.data || [];
|
||||
|
||||
let totalPulled = 0;
|
||||
|
||||
// Pull updates for each table
|
||||
for (const status of syncStatus) {
|
||||
try {
|
||||
const count = await this.pullTableChanges(status.table, status.lastSync);
|
||||
totalPulled += count;
|
||||
} catch (error) {
|
||||
console.error(`Failed to pull changes for ${status.table}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return totalPulled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes for a specific table
|
||||
*/
|
||||
private async pullTableChanges(tableName: string, lastSync: number): Promise<number> {
|
||||
if (!this.accessToken) return 0;
|
||||
|
||||
// Map table names to API endpoints
|
||||
let endpoint = '';
|
||||
|
||||
switch (tableName) {
|
||||
case 'erit_books':
|
||||
endpoint = 'books';
|
||||
break;
|
||||
case 'book_chapters':
|
||||
endpoint = 'chapters';
|
||||
break;
|
||||
case 'book_characters':
|
||||
endpoint = 'characters';
|
||||
break;
|
||||
case 'ai_conversations':
|
||||
endpoint = 'ai/conversations';
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request changes since last sync
|
||||
const response = await System.authGetQueryToServer<any>(
|
||||
`${endpoint}/sync?since=${lastSync}`,
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
if (!response || !response.data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const changes = Array.isArray(response.data) ? response.data : [response.data];
|
||||
|
||||
// Apply changes to local database
|
||||
// This would require implementing merge logic for each table
|
||||
// For now, we'll just log the changes
|
||||
|
||||
console.log(`Pulled ${changes.length} changes for ${tableName}`);
|
||||
|
||||
// Update last sync time via IPC
|
||||
// TODO: Add IPC handler for updating last sync
|
||||
|
||||
return changes.length;
|
||||
} catch (error) {
|
||||
console.error(`Failed to pull changes for ${tableName}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflicts between local and server data
|
||||
* Strategy: Server wins (can be customized)
|
||||
*/
|
||||
private resolveConflict(localData: any, serverData: any): any {
|
||||
// Simple strategy: server wins
|
||||
// TODO: Implement more sophisticated conflict resolution
|
||||
console.warn('Conflict detected, using server data');
|
||||
return serverData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync progress
|
||||
*/
|
||||
getSyncProgress(): SyncProgress {
|
||||
// This will be called synchronously, so we return cached state
|
||||
// The actual sync status is updated via events
|
||||
return {
|
||||
isSyncing: this.isSyncing,
|
||||
pendingChanges: 0, // Will be updated via IPC
|
||||
isOnline: this.isOnline
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
pushedChanges: number;
|
||||
pulledChanges: number;
|
||||
}
|
||||
|
||||
export interface SyncProgress {
|
||||
isSyncing: boolean;
|
||||
pendingChanges: number;
|
||||
isOnline: boolean;
|
||||
tables?: { table: string; lastSync: number; pending: number }[];
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let syncServiceInstance: SyncService | null = null;
|
||||
|
||||
export function getSyncService(): SyncService {
|
||||
if (!syncServiceInstance) {
|
||||
syncServiceInstance = new SyncService();
|
||||
}
|
||||
return syncServiceInstance;
|
||||
}
|
||||
Reference in New Issue
Block a user