Remove SyncService and introduce context-based offline mode and state management

- Delete `SyncService` and its associated bidirectional synchronization logic.
- Add multiple context providers (`OfflineProvider`, `AlertProvider`, `LangContext`, `UserContext`, `SessionContext`, `WorldContext`, `SettingBookContext`) for contextual state management.
- Implement `SecureStorage` for OS-level secure data encryption and replace dependency on `SyncService` synchronization.
- Update localization files (`en.json`, `fr.json`) with offline mode and error-related strings.
This commit is contained in:
natreex
2025-11-19 22:01:24 -05:00
parent f85c2d2269
commit 9e51cc93a8
20 changed files with 961 additions and 484 deletions

View File

@@ -833,7 +833,10 @@
"userNotFound": "User not found",
"authenticationError": "Error during authentication",
"termsAcceptError": "Error accepting terms of service",
"lastChapterError": "Error retrieving last chapter"
"lastChapterError": "Error retrieving last chapter",
"localDataError": "Unable to load local data",
"encryptionKeyError": "Encryption key not found",
"offlineModeError": "Error initializing offline mode"
}
},
"shortStoryGenerator": {

View File

@@ -834,7 +834,10 @@
"userNotFound": "Utilisateur non trouvé",
"authenticationError": "Erreur pendant l'authentification",
"termsAcceptError": "Erreur lors de l'acceptation des conditions d'utilisation",
"lastChapterError": "Erreur lors de la récupération du dernier chapitre"
"lastChapterError": "Erreur lors de la récupération du dernier chapitre",
"localDataError": "Impossible de charger les données locales",
"encryptionKeyError": "Clé de chiffrement non trouvée",
"offlineModeError": "Erreur lors de l'initialisation du mode hors ligne"
}
},
"shortStoryGenerator": {

View File

@@ -1,397 +0,0 @@
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;
}

View File

@@ -0,0 +1,106 @@
/**
* Database Error Handler for Frontend
* Handles errors from Electron IPC calls
*/
export interface SerializedError {
name: string;
message: string;
messageFr: string;
messageEn: string;
statusCode: number;
stack?: string;
}
/**
* Check if error is a serialized database error
*/
export function isDbError(error: unknown): error is SerializedError {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
'messageFr' in error &&
'messageEn' in error &&
'statusCode' in error
);
}
/**
* Get error message based on current language
*/
export function getErrorMessage(error: SerializedError, lang: 'fr' | 'en' = 'fr'): string {
return lang === 'fr' ? error.messageFr : error.messageEn;
}
/**
* Handle database operation with error catching
* Use this to wrap all IPC calls
*/
export async function handleDbOperation<T>(
operation: () => Promise<T>,
onError?: (error: SerializedError) => void,
lang: 'fr' | 'en' = 'fr'
): Promise<T> {
try {
return await operation();
} catch (error: unknown) {
if (isDbError(error)) {
const errorMessage = getErrorMessage(error, lang);
console.error(`[DB Error ${error.statusCode}]: ${errorMessage}`);
if (onError) {
onError(error);
} else {
// Default: throw with localized message
throw new Error(errorMessage);
}
}
// Not a database error, rethrow as-is
throw error;
}
}
/**
* React Hook for database operations
* Example usage in a React component:
*
* const { data, error, loading, execute } = useDbOperation();
*
* const loadBooks = async () => {
* await execute(() => window.electron.invoke('db:book:getAll'));
* };
*/
export function useDbOperation<T>() {
const [data, setData] = React.useState<T | null>(null);
const [error, setError] = React.useState<SerializedError | null>(null);
const [loading, setLoading] = React.useState<boolean>(false);
const execute = async (
operation: () => Promise<T>,
lang: 'fr' | 'en' = 'fr'
): Promise<T | null> => {
setLoading(true);
setError(null);
try {
const result = await handleDbOperation(
operation,
(err) => setError(err),
lang
);
setData(result);
setLoading(false);
return result;
} catch (err) {
setLoading(false);
return null;
}
};
return { data, error, loading, execute };
}
// For non-React usage
import React from 'react';