Files
ERitors-Scribe-Desktop/hooks/useSyncSeries.ts
natreex 49bb6e06f5 Add deletedAt timestamps to delete operations for better audit tracking
- Updated delete methods across hooks and components to include `deletedAt: System.timeStampInSeconds()`.
- Refactored synchronized delete logic to pass `deletedAt` for both offline and online states.
- Improved synchronization workflows to include `deletedAt` in server and IPC requests.
- Enhanced destructuring patterns for cleaner and more consistent request data.
2026-02-09 17:12:03 -05:00

356 lines
13 KiB
TypeScript

import { useContext } from 'react';
import System from '@/lib/models/System';
import { SessionContext } from '@/context/SessionContext';
import { LangContext } from '@/context/LangContext';
import { AlertContext } from '@/context/AlertContext';
import OfflineContext from '@/context/OfflineContext';
import { SeriesSyncContext } from '@/context/SeriesSyncContext';
import { SeriesSyncCompare, SyncedSeries } from '@/lib/models/SyncedSeries';
import { useTranslations } from 'next-intl';
interface RemovedItemRecord {
removal_id: string;
table_name: string;
entity_id: string;
book_id: string | null;
user_id: string;
deleted_at: number;
}
interface SyncedSeriesResponse {
series: SyncedSeries[];
tombstones: RemovedItemRecord[];
}
/**
* Complete series data structure for full upload/download operations.
* Mirrors the backend CompleteSeries interface.
*/
interface CompleteSeries {
series: unknown[];
seriesBooks: unknown[];
seriesCharacters: unknown[];
seriesCharacterAttributes: unknown[];
seriesWorlds: unknown[];
seriesWorldElements: unknown[];
seriesLocations: unknown[];
seriesLocationElements: unknown[];
seriesLocationSubElements: unknown[];
seriesSpells: unknown[];
seriesSpellTags: unknown[];
}
/**
* Hook for managing series synchronization between local database and server.
* Provides methods for upload, download, and partial sync operations.
*/
export default function useSyncSeries() {
const t = useTranslations();
const { session } = useContext(SessionContext);
const { lang } = useContext(LangContext);
const { errorMessage } = useContext(AlertContext);
const { isCurrentlyOffline, offlineMode } = useContext(OfflineContext);
const {
seriesToSyncToServer,
seriesToSyncFromServer,
localOnlySeries,
serverOnlySeries,
setLocalOnlySeries,
setServerOnlySeries,
setServerSyncedSeries,
setLocalSyncedSeries,
setSeriesToSyncFromServer,
setSeriesToSyncToServer
} = useContext(SeriesSyncContext);
/**
* Uploads a local-only series to the server.
* @param seriesId - The ID of the series to upload
* @returns True if upload was successful, false otherwise
*/
async function upload(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const seriesToSync: CompleteSeries = await window.electron.invoke<CompleteSeries>('db:series:uploadToServer', seriesId);
if (!seriesToSync) {
errorMessage(t('seriesCard.uploadError'));
return false;
}
const response: boolean = await System.authPostToServer('series/sync/upload', {
series: seriesToSync
}, session.accessToken, lang);
if (!response) {
errorMessage(t('seriesCard.uploadError'));
return false;
}
// Move series from local-only to synced
const uploadedSeries: SyncedSeries | undefined = localOnlySeries.find(
(series: SyncedSeries): boolean => series.id === seriesId
);
setLocalOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => {
return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId);
});
if (uploadedSeries) {
setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]);
setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]);
}
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.uploadError'));
}
return false;
}
}
/**
* Downloads a server-only series to the local database.
* @param seriesId - The ID of the series to download
* @returns True if download was successful, false otherwise
*/
async function download(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const response: CompleteSeries = await System.authGetQueryToServer(
'series/sync/download',
session.accessToken,
lang,
{ seriesId }
);
if (!response) {
errorMessage(t('seriesCard.downloadError'));
return false;
}
const syncStatus: boolean = await window.electron.invoke<boolean>('db:series:syncSave', response);
if (!syncStatus) {
errorMessage(t('seriesCard.downloadError'));
return false;
}
// Move series from server-only to synced
const downloadedSeries: SyncedSeries | undefined = serverOnlySeries.find(
(series: SyncedSeries): boolean => series.id === seriesId
);
setServerOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => {
return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId);
});
if (downloadedSeries) {
setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]);
setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]);
}
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.downloadError'));
}
return false;
}
}
/**
* Syncs changes from server to local database for a specific series.
* Only transfers entities that have changed based on the comparison.
* @param seriesId - The ID of the series to sync
* @returns True if sync was successful, false otherwise
*/
async function syncFromServer(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncFromServer.find(
(series: SeriesSyncCompare): boolean => series.id === seriesId
);
if (!seriesToFetch) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
const response: CompleteSeries = await System.authPostToServer(
'series/sync/server-to-client',
{ seriesToSync: seriesToFetch },
session.accessToken,
lang
);
if (!response) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
const syncStatus: boolean = await window.electron.invoke<boolean>('db:series:sync:toClient', response);
if (!syncStatus) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
// Remove from pending sync list
setSeriesToSyncFromServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] =>
prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.syncFromServerError'));
}
return false;
}
}
/**
* Syncs local changes to the server for a specific series.
* Only transfers entities that have changed based on the comparison.
* @param seriesId - The ID of the series to sync
* @returns True if sync was successful, false otherwise
*/
async function syncToServer(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) {
return false;
}
try {
const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncToServer.find(
(series: SeriesSyncCompare): boolean => series.id === seriesId
);
if (!seriesToFetch) {
// La série n'est plus dans la liste - probablement déjà sync par AutoSyncOnReconnect
// Retourner true car ce n'est pas une erreur, juste déjà fait
return true;
}
const seriesToSync: CompleteSeries = await window.electron.invoke<CompleteSeries>(
'db:series:sync:toServer',
seriesToFetch
);
if (!seriesToSync) {
errorMessage(t('seriesCard.syncToServerError'));
return false;
}
const response: boolean = await System.authPatchToServer(
'series/sync/client-to-server',
{ series: seriesToSync },
session.accessToken,
lang
);
if (!response) {
errorMessage(t('seriesCard.syncToServerError'));
return false;
}
// Remove from pending sync list
setSeriesToSyncToServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] =>
prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.syncToServerError'));
}
return false;
}
}
/**
* Syncs all series that have local changes to the server.
*/
async function syncAllToServer(): Promise<void> {
for (const diff of seriesToSyncToServer) {
await syncToServer(diff.id);
}
}
async function syncAllFromServer(): Promise<void> {
for (const diff of seriesToSyncFromServer) {
await syncFromServer(diff.id);
}
}
async function refreshSeries(): Promise<void> {
try {
let localSeriesResponse: SyncedSeries[] = [];
let serverSeriesResponse: SyncedSeries[] = [];
if (!isCurrentlyOffline()) {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
// Get lastOnlineTimestamp from localStorage (or 0 if not set)
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
// Get local tombstones since lastOnlineTimestamp via IPC
const localTombstones: RemovedItemRecord[] = await window.electron.invoke<RemovedItemRecord[]>(
'db:tombstones:since',
lastOnlineTimestamp
);
// Call server with POST and tombstones
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp, tombstones: localTombstones },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
// Apply server tombstones locally via IPC
await window.electron.invoke<void>('db:tombstones:apply:series', serverResponse.tombstones);
} else {
// No local DB but online - just get server series without tombstones
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp: 0, tombstones: [] },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
}
}
setServerSyncedSeries(serverSeriesResponse);
setLocalSyncedSeries(localSeriesResponse);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.refreshError'));
}
}
}
return {
upload,
download,
syncFromServer,
syncToServer,
syncAllToServer,
syncAllFromServer,
refreshSeries,
localOnlySeries,
serverOnlySeries,
seriesToSyncToServer,
seriesToSyncFromServer
};
}