Files
ERitors-Scribe-Desktop/hooks/useSyncSeries.ts
natreex b9bc024e91 Introduce local fallback for book creation and improve error handling
- Added support for creating books locally when the cloud limit is reached.
- Enhanced error handling in `AddNewBookForm` with `ApiError` and fallback logic for local book creation.
- Implemented `BookTypeLimit` to manage type-specific book limits with visual indicators in `BookList`.
- Refactored `TombstoneRecord` to standardize naming conventions for better API compatibility.
- Updated `useSyncSeries` and `useSyncBooks` to handle empty tombstones gracefully.
- Updated locales with new translations for fallback and error messaging.
2026-03-31 09:18:11 -04:00

347 lines
13 KiB
TypeScript

import { useContext } from 'react';
import {apiGet, apiPost, apiPatch} from '@/lib/api/client';
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/types/synced-series';
import { useTranslations } from '@/lib/i18n';
import * as tauri from '@/lib/tauri';
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 tauri.uploadSeriesToServer(seriesId) as CompleteSeries;
if (!seriesToSync) {
errorMessage(t('seriesCard.uploadError'));
return false;
}
const response: boolean = await apiPost('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 apiGet(
'series/sync/download',
session.accessToken,
lang,
{ seriesId }
);
if (!response) {
errorMessage(t('seriesCard.downloadError'));
return false;
}
const syncStatus: boolean = await tauri.syncSaveSeries(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 apiPost(
'series/sync/server-to-client',
{ seriesToSync: seriesToFetch },
session.accessToken,
lang
);
if (!response) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
const syncStatus: boolean = await tauri.syncSeriesToClient(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 tauri.syncSeriesToServer(seriesToFetch) as CompleteSeries;
if (!seriesToSync) {
errorMessage(t('seriesCard.syncToServerError'));
return false;
}
const response: boolean = await apiPatch(
'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 tauri.getSyncedSeries() as SyncedSeries[];
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[];
const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp, tombstones: localTombstones },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
await tauri.applySeriesTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]);
} else {
// No local DB but online - just get server series without tombstones
const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp: 0, tombstones: [] },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[];
}
}
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
};
}