Tuple of [synchronization state, synchronization actions].
export function useSynchronization(): [
SynchronizationState,
SynchronizationActions,
] {
const [state, setState] = useState<SynchronizationState>({
isActive: false,
progress: null,
report: null,
error: null,
abortController: null,
isPaused: false,
resumeAvailable: false,
resumeMetadata: null,
});
const [failedOperations, setFailedOperations] = useState<FailedOperation[]>(
[],
);
const [isLoadingFailedOps, setIsLoadingFailedOps] = useState(false);
// Get auth state for token in retry operations
const { authState, isOnline } = useAuthState();
const resumeSnapshotRef = useRef<SyncResumeSnapshot | null>(null);
const initialEntriesRef = useRef<AniListMediaEntry[]>([]);
const uniqueMediaIdsRef = useRef<number[]>([]);
const pauseRequestedRef = useRef(false);
const resumeRequestedRef = useRef(false);
const hasLoadedSnapshotRef = useRef(false);
const { registerStateInspector: registerSyncStateInspector, recordEvent } =
useDebugActions();
const syncInspectorHandleRef =
useRef<StateInspectorHandle<SyncDebugSnapshot> | null>(null);
const syncSnapshotRef = useRef<SyncDebugSnapshot | null>(null);
const getSyncSnapshotRef = useRef<() => SyncDebugSnapshot>(() => ({
state: toDebugSyncState(state),
resumeSnapshot: resumeSnapshotRef.current
? {
...resumeSnapshotRef.current,
entries: cloneEntries(resumeSnapshotRef.current.entries),
}
: null,
pendingEntriesCount: initialEntriesRef.current.length,
uniqueMediaIds: [...uniqueMediaIdsRef.current],
pauseRequested: pauseRequestedRef.current,
resumeRequested: resumeRequestedRef.current,
}));
getSyncSnapshotRef.current = () => ({
state: toDebugSyncState(state),
resumeSnapshot: resumeSnapshotRef.current
? {
...resumeSnapshotRef.current,
entries: cloneEntries(resumeSnapshotRef.current.entries),
}
: null,
pendingEntriesCount: initialEntriesRef.current.length,
uniqueMediaIds: [...uniqueMediaIdsRef.current],
pauseRequested: pauseRequestedRef.current,
resumeRequested: resumeRequestedRef.current,
});
const emitSyncSnapshot = useCallback(() => {
if (!syncInspectorHandleRef.current) return;
const snapshot = getSyncSnapshotRef.current();
syncSnapshotRef.current = snapshot;
syncInspectorHandleRef.current.publish(snapshot);
}, []);
const applySyncDebugSnapshot = useCallback(
(snapshot: SyncDebugSnapshot) => {
if (snapshot.state) {
setState((prev) => ({
...prev,
...fromDebugSyncState(snapshot.state),
}));
}
if (Array.isArray(snapshot.uniqueMediaIds)) {
uniqueMediaIdsRef.current = [...snapshot.uniqueMediaIds];
}
if (typeof snapshot.pauseRequested === "boolean") {
pauseRequestedRef.current = snapshot.pauseRequested;
}
if (typeof snapshot.resumeRequested === "boolean") {
resumeRequestedRef.current = snapshot.resumeRequested;
}
if (snapshot.resumeSnapshot !== undefined) {
resumeSnapshotRef.current = snapshot.resumeSnapshot
? {
...snapshot.resumeSnapshot,
entries: cloneEntries(snapshot.resumeSnapshot.entries || []),
}
: null;
initialEntriesRef.current = snapshot.resumeSnapshot
? cloneEntries(snapshot.resumeSnapshot.entries || [])
: [];
}
syncSnapshotRef.current = getSyncSnapshotRef.current();
emitSyncSnapshot();
},
[emitSyncSnapshot, setState],
);
useEffect(() => {
emitSyncSnapshot();
}, [state, emitSyncSnapshot]);
useEffect(() => {
if (!registerSyncStateInspector) return;
syncSnapshotRef.current = getSyncSnapshotRef.current();
const handle = registerSyncStateInspector<SyncDebugSnapshot>({
id: "sync-state",
label: "Synchronization",
description:
"AniList sync engine status, resume metadata, and control signals.",
group: "Application",
getSnapshot: () =>
syncSnapshotRef.current ?? getSyncSnapshotRef.current(),
setSnapshot: applySyncDebugSnapshot,
});
syncInspectorHandleRef.current = handle;
return () => {
handle.unregister();
syncInspectorHandleRef.current = null;
syncSnapshotRef.current = null;
};
}, [registerSyncStateInspector, applySyncDebugSnapshot]);
const updateSnapshotFromProgress = (
progress: SyncProgress | null,
entriesForRun: AniListMediaEntry[],
) => {
if (!progress) {
resumeSnapshotRef.current = null;
initialEntriesRef.current = [];
uniqueMediaIdsRef.current = [];
saveSnapshotToStorage(null);
setState((prev) => ({
...prev,
resumeAvailable: false,
resumeMetadata: null,
}));
return;
}
const uniqueMediaIds = uniqueMediaIdsRef.current;
if (!uniqueMediaIds.length) {
resumeSnapshotRef.current = null;
saveSnapshotToStorage(null);
return;
}
const completedCount = Math.min(progress.completed, uniqueMediaIds.length);
const completedMediaIds = uniqueMediaIds.slice(0, completedCount);
const remainingMediaIds = uniqueMediaIds.slice(completedCount);
if (
remainingMediaIds.length === 0 &&
(!progress.currentEntry || completedCount === uniqueMediaIds.length)
) {
resumeSnapshotRef.current = null;
initialEntriesRef.current = [];
saveSnapshotToStorage(null);
setState((prev) => ({
...prev,
resumeAvailable: false,
resumeMetadata: null,
}));
return;
}
const pendingEntries = entriesForRun.filter((entry) =>
remainingMediaIds.includes(entry.mediaId),
);
if (progress.currentEntry) {
const pendingIndex = pendingEntries.findIndex(
(entry) => entry.mediaId === progress.currentEntry?.mediaId,
);
if (pendingIndex !== -1) {
const metadata = pendingEntries[pendingIndex].syncMetadata;
if (metadata?.useIncrementalSync && progress.currentStep) {
pendingEntries[pendingIndex] = {
...pendingEntries[pendingIndex],
syncMetadata: {
...metadata,
resumeFromStep: progress.currentStep,
},
};
}
}
}
const snapshot: SyncResumeSnapshot = {
entries: cloneEntries(pendingEntries),
uniqueMediaIds: [...uniqueMediaIds],
completedMediaIds,
remainingMediaIds,
progress: {
...progress,
currentEntry: progress.currentEntry
? { ...progress.currentEntry }
: null,
},
currentEntry: progress.currentEntry
? {
mediaId: progress.currentEntry.mediaId,
resumeFromStep: progress.currentStep ?? undefined,
}
: null,
reportFragment: resumeSnapshotRef.current?.reportFragment ?? null,
timestamp: Date.now(),
};
resumeSnapshotRef.current = snapshot;
initialEntriesRef.current = cloneEntries(pendingEntries);
// NOTE: Snapshot persistence deferred to batch completion callback (appendBatchResultToSnapshot)
// to reduce redundant storage writes. Snapshot is updated in memory here but only persisted on batch boundary.
setState((prev) => ({
...prev,
resumeAvailable: true,
resumeMetadata: {
remainingMediaIds,
timestamp: snapshot.timestamp,
},
}));
emitSyncSnapshot();
};
/**
* If automatic backups are enabled and this is a fresh sync, attempt to create one.
* Errors are non-blocking and will be recorded to Sentry/breadcrumbs.
*/
const performAutomaticBackupIfNeeded = async (isResume: boolean) => {
if (isResume) return;
// Verify Electron backup context is available
if (!globalThis.electronBackup?.getScheduleConfig) {
console.warn(
"[Synchronization] ⚠️ Electron backup context not available - skipping automatic backup",
);
return;
}
// Get schedule config and check if automatic backup before sync is enabled
const config = await globalThis.electronBackup.getScheduleConfig();
if (!config.autoBackupBeforeSync) {
return;
}
try {
console.info(
"[Synchronization] 📦 Creating automatic silent backup before sync...",
);
const result = await globalThis.electronBackup.createNow();
if (!result.success) {
throw new Error(result.error || "Failed to create backup");
}
console.info(
`[Synchronization] ✅ Automatic backup created successfully: ${result.backupId}`,
);
Sentry.addBreadcrumb({
category: "backup",
message: "Automatic backup created before sync",
level: "info",
data: {
backupId: result.backupId,
},
});
} catch (backupError) {
console.warn(
"[Synchronization] ⚠️ Automatic backup failed (non-blocking):",
backupError,
);
Sentry.addBreadcrumb({
category: "backup",
message: "Automatic backup failed before sync",
level: "warning",
data: {
error:
backupError instanceof Error
? backupError.message
: String(backupError),
},
});
}
};
/**
* Records reading history snapshots after successful sync.
* Captures current chapter progress for all synced manga.
*/
const recordSyncHistory = () => {
try {
const matchResults = getSavedMatchResults();
if (!matchResults || matchResults.length === 0) return;
const now = Date.now();
const historyEntries: ReadingHistoryEntry[] = [];
for (const match of matchResults) {
// Only record for matched/manual entries with chapter progress
if (!["matched", "manual"].includes(match.status ?? "")) continue;
if (!match.kenmeiManga?.chaptersRead) continue;
if (match.kenmeiManga.chaptersRead <= 0) continue;
// Prefer reading time from kenmeiManga.last_read_at if available
let timestamp = now;
if (match.kenmeiManga.lastReadAt) {
const readTime = new Date(match.kenmeiManga.lastReadAt).getTime();
if (!Number.isNaN(readTime)) {
timestamp = readTime;
}
}
historyEntries.push({
timestamp,
mangaId: match.kenmeiManga.id,
title: match.kenmeiManga.title,
chaptersRead: match.kenmeiManga.chaptersRead,
status: match.kenmeiManga.status || "unknown",
anilistId: match.selectedMatch?.id,
});
}
if (historyEntries.length > 0) {
recordReadingHistory(historyEntries);
console.info(
`[Synchronization] 📊 Recorded reading history: ${historyEntries.length} entries`,
);
}
} catch (error) {
console.warn(
"[Synchronization] ⚠️ Failed to record reading history (non-blocking):",
error,
);
}
};
/**
* Initialize controller, ids, refs and initial progress state for a sync run.
* @param entries - Array of AniList media entries to sync.
* @param displayOrderMediaIds - Optional array of media IDs in display order.
* @returns Object containing abort controller, unique media IDs, and initial progress state.
* @source
*/
const initializeSyncRun = (
entries: AniListMediaEntry[],
displayOrderMediaIds?: number[],
) => {
const abortController = new AbortController();
const uniqueMediaIds =
displayOrderMediaIds && displayOrderMediaIds.length > 0
? displayOrderMediaIds
: Array.from(new Set(entries.map((e) => e.mediaId)));
uniqueMediaIdsRef.current = [...uniqueMediaIds];
initialEntriesRef.current = cloneEntries(entries);
const initialProgress: SyncProgress = {
total: uniqueMediaIds.length,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
currentEntry: null,
currentStep: null,
totalSteps: null,
rateLimited: false,
retryAfter: null,
};
setState((prev) => ({
...prev,
isActive: true,
error: null,
abortController,
progress: initialProgress,
isPaused: false,
resumeAvailable: false,
resumeMetadata: null,
}));
updateSnapshotFromProgress(initialProgress, initialEntriesRef.current);
saveSnapshotToStorage(resumeSnapshotRef.current);
return { abortController, uniqueMediaIds, initialProgress };
};
/**
* Run the core sync execution and return the resulting report and last progress.
* @param entries - Array of AniList media entries to sync.
* @param token - AniList authentication token.
* @param abortController - Abort controller for cancellation.
* @param uniqueMediaIds - Array of unique media IDs to sync.
* @returns Sync execution result with report and final progress.
* @source
*/
const runSyncExecution = async (
entries: AniListMediaEntry[],
token: string,
abortController: AbortController,
uniqueMediaIds: number[],
) => {
const executed = await executeSyncModeImpl(
entries,
token,
abortController,
{
uniqueMediaIds,
setState,
updateSnapshotFromProgress,
initialEntriesRef,
resumeSnapshotRef,
},
);
return executed;
};
const clearResumeSnapshot = () => {
resumeSnapshotRef.current = null;
initialEntriesRef.current = [];
uniqueMediaIdsRef.current = [];
saveSnapshotToStorage(null);
setState((prev) => ({
...prev,
resumeAvailable: false,
resumeMetadata: null,
}));
emitSyncSnapshot();
};
// Load failed operations on mount
useEffect(() => {
setIsLoadingFailedOps(true);
try {
const queue = getFailedOperations();
// Filter to only sync operations
const syncOps = queue.operations.filter(
(op) => op.type === "sync_update" || op.type === "sync_delete",
);
setFailedOperations(syncOps);
console.debug(
`[Synchronization] Loaded ${syncOps.length} failed operations`,
);
} catch (error) {
console.error(
"[Synchronization] Failed to load failed operations:",
error,
);
} finally {
setIsLoadingFailedOps(false);
}
}, []);
/**
* Helper function to refresh failed operations from storage.
* Call after sync operations to pick up newly persisted failures.
*/
const refreshFailedOperations = useCallback(() => {
try {
const queue = getFailedOperations();
const syncOps = queue.operations.filter(
(op) => op.type === "sync_update" || op.type === "sync_delete",
);
setFailedOperations(syncOps);
console.debug(
`[Synchronization] Refreshed failed operations: ${syncOps.length} total`,
);
} catch (error) {
console.error(
"[Synchronization] Failed to refresh failed operations:",
error,
);
}
}, []);
useEffect(() => {
if (hasLoadedSnapshotRef.current) return;
hasLoadedSnapshotRef.current = true;
const storedSnapshot = storage.getItem(STORAGE_KEYS.ACTIVE_SYNC_SNAPSHOT);
if (!storedSnapshot) {
console.debug("[Synchronization] 🔍 No stored sync snapshot found");
return;
}
console.debug("[Synchronization] 🔍 Loading stored sync snapshot...");
try {
const parsed: SyncResumeSnapshot = JSON.parse(storedSnapshot);
// Validate snapshot structure using centralized validation
const validation = validateSyncSnapshot(parsed);
if (!validation.valid) {
console.warn(
"[Synchronization] ⚠️ Snapshot validation failed: " +
(validation.reason || "unknown reason"),
);
storage.removeItem(STORAGE_KEYS.ACTIVE_SYNC_SNAPSHOT);
return;
}
// Check if snapshot is stale using centralized staleness check
if (isSyncSnapshotStale(parsed.timestamp)) {
console.warn(
`[Synchronization] ⚠️ Snapshot is stale (${Math.round((Date.now() - parsed.timestamp) / (60 * 60 * 1000))} hours old), removing...`,
);
storage.removeItem(STORAGE_KEYS.ACTIVE_SYNC_SNAPSHOT);
return;
}
console.info(
`[Synchronization] ✅ Loaded sync snapshot: ${parsed.remainingMediaIds.length} remaining entries`,
);
resumeSnapshotRef.current = {
...parsed,
progress: {
...parsed.progress,
currentEntry: parsed.progress.currentEntry
? { ...parsed.progress.currentEntry }
: null,
},
};
initialEntriesRef.current = cloneEntries(parsed.entries || []);
uniqueMediaIdsRef.current = [...parsed.uniqueMediaIds];
setState((prev) => ({
...prev,
progress: parsed.progress ?? prev.progress,
isPaused: true,
resumeAvailable: true,
resumeMetadata: {
remainingMediaIds: parsed.remainingMediaIds,
timestamp: parsed.timestamp,
},
}));
} catch (error) {
console.error(
"[Synchronization] ❌ Failed to load stored sync snapshot:",
error,
);
storage.removeItem(STORAGE_KEYS.ACTIVE_SYNC_SNAPSHOT);
}
}, []);
/**
* Starts a new synchronization for the provided AniList media entries.
* @param entries - AniList media entries to synchronize.
* @param token - AniList authentication token.
* @param _unused - Reserved for future use.
* @param displayOrderMediaIds - Optional IDs to control sync order.
* @returns Promise resolving when sync completes or fails.
* @source
*/
const startSync = useCallback(
async (
entries: AniListMediaEntry[],
token: string,
_unused?: undefined,
displayOrderMediaIds?: number[],
) => {
if (state.isActive) {
console.warn("[Synchronization] ⚠️ Sync is already in progress");
return;
}
if (!entries.length) {
console.warn("[Synchronization] ⚠️ No entries to synchronize");
setState((prev) => ({ ...prev, error: "No entries to synchronize" }));
return;
}
if (!token) {
console.error("[Synchronization] ❌ No authentication token available");
setState((prev) => ({
...prev,
error: "No authentication token available",
}));
return;
}
const isResume = resumeRequestedRef.current;
resumeRequestedRef.current = false;
console.info(
`[Synchronization] ${isResume ? "▶️ Resuming" : "🚀 Starting"} sync for ${entries.length} entries`,
);
Sentry.addBreadcrumb({
category: "sync",
message: `Sync ${isResume ? "resumed" : "started"}`,
level: "info",
data: { entryCount: entries.length, isResume },
});
recordEvent({
type: isResume ? "sync.resume" : "sync.start",
message: `${isResume ? "Resumed" : "Started"} sync for ${entries.length} entries`,
level: "info",
metadata: { entryCount: entries.length, isResume },
});
if (!isResume && resumeSnapshotRef.current) {
console.debug(
"[Synchronization] 🔍 Clearing existing resume snapshot for fresh sync",
);
clearResumeSnapshot();
}
const existingReportFragment = isResume
? fromPersistedReport(resumeSnapshotRef.current?.reportFragment ?? null)
: null;
pauseRequestedRef.current = false;
// Try to create a backup if configured (non-blocking)
await performAutomaticBackupIfNeeded(isResume);
// Main orchestration
try {
const { abortController, uniqueMediaIds } = initializeSyncRun(
entries,
displayOrderMediaIds,
);
const executed = await runSyncExecution(
entries,
token,
abortController,
uniqueMediaIds,
);
const syncReport = executed.syncReport;
const lastReportedProgress = executed.lastReportedProgress;
if (pauseRequestedRef.current) {
console.info(
"[Synchronization] ⏸️ Pausing sync and saving state for resume...",
);
Sentry.addBreadcrumb({
category: "sync",
message: "Sync paused by user",
level: "info",
data: { progress: lastReportedProgress },
});
handlePausedSync(
existingReportFragment,
syncReport,
lastReportedProgress,
resumeSnapshotRef,
updateSnapshotFromProgress,
initialEntriesRef,
setState,
);
console.info("[Synchronization] ✅ Sync paused successfully");
return;
}
console.debug("[Synchronization] 🔍 Finalizing sync operation...");
finalizeSyncOperation(
existingReportFragment,
syncReport,
pauseRequestedRef.current,
clearResumeSnapshot,
setState,
);
// Refresh failed operations state to reflect any newly persisted failures
refreshFailedOperations();
const finalReport = mergeReports(existingReportFragment, syncReport);
// Record reading history snapshot after successful sync
if (finalReport.successfulUpdates > 0) {
recordSyncHistory();
}
Sentry.addBreadcrumb({
category: "sync",
message: "Sync completed",
level: finalReport.failedUpdates > 0 ? "warning" : "info",
data: {
successfulUpdates: finalReport.successfulUpdates,
failedUpdates: finalReport.failedUpdates,
skippedEntries: finalReport.skippedEntries,
totalEntries: finalReport.totalEntries,
},
});
Sentry.setContext("sync", {
totalEntries: finalReport.totalEntries,
successfulUpdates: finalReport.successfulUpdates,
failedUpdates: finalReport.failedUpdates,
skippedEntries: finalReport.skippedEntries,
});
recordEvent({
type: "sync.complete",
message: `Sync completed: ${finalReport.successfulUpdates} success, ${finalReport.failedUpdates} failed`,
level: finalReport.failedUpdates > 0 ? "warn" : "success",
metadata: {
totalEntries: finalReport.totalEntries,
successfulUpdates: finalReport.successfulUpdates,
failedUpdates: finalReport.failedUpdates,
skippedEntries: finalReport.skippedEntries,
},
});
} catch (error) {
console.error("[Synchronization] Sync operation failed:", error);
captureError(
ErrorType.UNKNOWN,
"Synchronization operation failed",
error,
{
entryCount: entries.length,
isResume,
progress: state.progress,
stage: "sync_execution",
},
);
setState((prev) => ({
...prev,
isActive: false,
error: error instanceof Error ? error.message : String(error),
abortController: null,
isPaused: false,
resumeAvailable: prev.resumeAvailable,
}));
}
},
[clearResumeSnapshot, state.isActive],
);
/**
* Cancels the active synchronization, aborting all in-progress requests immediately.
* Clears pending partial results and disables resume.
* @source
*/
const cancelSync = useCallback(() => {
if (state.abortController) {
console.info(
"[Synchronization] Cancellation requested - aborting all sync operations",
);
recordEvent({
type: "sync.cancel",
message: "Sync cancelled by user",
level: "warn",
metadata: {
progress: state.progress,
},
});
state.abortController.abort();
setState((prev) => ({
...prev,
isActive: false,
abortController: null,
// Set a special error string for cancellation
error: "Synchronization cancelled by user",
// Preserve the current report if any (partial results)
report: prev.report || null,
isPaused: false,
resumeAvailable: false,
resumeMetadata: null,
}));
pauseRequestedRef.current = false;
clearResumeSnapshot();
// Add a message to make it clear the operation has been canceled
console.info(
"%c🛑 [Synchronization] SYNC CANCELLED - All operations stopped",
"color: red; font-weight: bold",
);
}
}, [clearResumeSnapshot, state.abortController]);
const pauseSync = useCallback(() => {
if (state.abortController) {
console.info(
"[Synchronization] ⏸️ Pause requested - stopping current sync after current task",
);
recordEvent({
type: "sync.pause",
message: "Sync paused by user",
level: "info",
metadata: {
progress: state.progress,
},
});
pauseRequestedRef.current = true;
state.abortController.abort();
emitSyncSnapshot();
} else {
console.warn(
"[Synchronization] ⚠️ Cannot pause - no active sync operation",
);
}
}, [state.abortController, emitSyncSnapshot]);
/**
* Resumes a previously paused synchronization with remaining entries.
* Restores state from pause snapshot and continues processing.
* @param entries - AniList media entries (used if snapshot entries unavailable).
* @param token - AniList authentication token.
* @param _unused - Reserved for future use.
* @param displayOrderMediaIds - Optional IDs to override display order during resume.
* @returns Promise resolving when resume completes or fails.
* @source
*/
const resumeSync = useCallback(
async (
entries: AniListMediaEntry[],
token: string,
_unused?: undefined,
displayOrderMediaIds?: number[],
) => {
const snapshot = resumeSnapshotRef.current;
if (!snapshot) {
console.warn(
"[Synchronization] ⚠️ No paused synchronization state available to resume",
);
return;
}
const remainingIds = snapshot.remainingMediaIds;
if (!remainingIds || remainingIds.length === 0) {
console.warn(
"[Synchronization] ⚠️ Paused state contains no remaining entries",
);
clearResumeSnapshot();
return;
}
console.info(
`[Synchronization] ▶️ Resuming sync with ${remainingIds.length} remaining entries`,
);
const sourceEntries =
snapshot.entries && snapshot.entries.length > 0
? snapshot.entries
: entries;
const entriesToResume = cloneEntries(
(sourceEntries.length > 0 ? sourceEntries : entries).filter((entry) =>
remainingIds.includes(entry.mediaId),
),
);
if (!entriesToResume.length) {
console.warn(
"[Synchronization] ⚠️ No matching entries found to resume",
);
clearResumeSnapshot();
return;
}
console.debug(
`[Synchronization] 🔍 Prepared ${entriesToResume.length} entries for resume`,
);
resumeRequestedRef.current = true;
emitSyncSnapshot();
await startSync(
entriesToResume,
token,
_unused,
displayOrderMediaIds?.length ? displayOrderMediaIds : remainingIds,
);
},
[clearResumeSnapshot, emitSyncSnapshot, startSync],
);
/**
* Exports the error log from the last synchronization report.
* If no report is available, this function does nothing.
* @source
*/
const exportErrors = useCallback(() => {
if (state.report) {
console.info("[Synchronization] 📤 Exporting error log...");
exportSyncErrorLog(state.report);
console.info("[Synchronization] ✅ Error log exported successfully");
} else {
console.warn("[Synchronization] ⚠️ No sync report available to export");
}
}, [state.report]);
/**
* Exports the complete synchronization report to a file.
* If no report is available, this function does nothing.
* @source
*/
const exportReport = useCallback(() => {
if (state.report) {
console.info("[Synchronization] 📤 Exporting sync report...");
exportSyncReport(state.report);
console.info("[Synchronization] ✅ Sync report exported successfully");
} else {
console.warn("[Synchronization] ⚠️ No sync report available to export");
}
}, [state.report]);
/**
* Resets synchronization state to initial values, clearing all progress and reports.
* @source
*/
const reset = useCallback(() => {
console.info("[Synchronization] 🔄 Resetting synchronization state...");
clearResumeSnapshot();
setState({
isActive: false,
progress: null,
report: null,
error: null,
abortController: null,
isPaused: false,
resumeAvailable: false,
resumeMetadata: null,
});
emitSyncSnapshot();
}, [clearResumeSnapshot, emitSyncSnapshot]);
// Retry a single failed operation
const retryFailedOperation = useCallback(
async (operationId: string): Promise<boolean> => {
try {
// Check if online before attempting retry
if (!isOnline) {
console.warn(
"[Synchronization] Cannot retry operation: application is offline",
);
captureError(
ErrorType.NETWORK,
"Cannot retry: application is offline",
new Error("Offline during retry attempt"),
{
context: "retryFailedOperation",
operationId,
},
);
return false;
}
// Validate token is available
if (!authState?.accessToken) {
console.error(
"[Synchronization] Cannot retry operation: access token is missing or empty",
);
// Capture error for monitoring
captureError(
ErrorType.AUTH,
"Cannot retry: access token unavailable",
new Error("Cannot retry: access token unavailable"),
{
context: "retryFailedOperation",
operationId,
},
);
return false;
}
const operation = failedOperations.find((op) => op.id === operationId);
if (!operation) {
console.warn(
`[Synchronization] Failed operation not found: ${operationId}`,
);
return false;
}
// Check if max retries exceeded
if (operation.retryCount >= MAX_RETRY_ATTEMPTS) {
console.warn(
`[Synchronization] Max retries exceeded for operation: ${operationId}`,
);
return false;
}
// Extract entry data from payload
const payload = operation.payload as Record<string, unknown>;
// Validate and cast status to MediaListStatus
const validStatuses: MediaListStatus[] = [
"CURRENT",
"PLANNING",
"COMPLETED",
"DROPPED",
"PAUSED",
"REPEATING",
];
const statusString = (payload.status as string) || "";
const status = validStatuses.includes(statusString as MediaListStatus)
? (statusString as MediaListStatus)
: "PLANNING";
const entry: AniListMediaEntry = {
mediaId: (payload.mediaId as number) || 0,
title: (payload.title as string) ?? "(Untitled)",
status,
progress: (payload.progress as number) || 0,
score: (payload.score as number) || 0,
private: (payload.private as boolean) ?? false,
previousValues:
(payload.previousValues as AniListMediaEntry["previousValues"]) ??
null,
syncMetadata:
(payload.syncMetadata as AniListMediaEntry["syncMetadata"]) ?? null,
};
// Increment retry count in storage
incrementRetryCount(operationId);
// Acquire rate-limit slot to ensure proper spacing
await acquireRateLimit();
// Create single-entry array and call sync logic with token
const batchResult = await syncMangaBatch(
[entry],
authState.accessToken,
() => {},
);
if (batchResult.successfulUpdates > 0) {
// Success - remove from failed operations
removeFailedOperation(operationId);
setFailedOperations((prev) =>
prev.filter((op) => op.id !== operationId),
);
console.info(
`[Synchronization] Successfully retried operation: ${operationId}`,
);
return true;
} else {
// Still failed - update in failed operations
console.debug(
`[Synchronization] Retry still failed for operation: ${operationId}`,
);
return false;
}
} catch (error) {
console.error("[Synchronization] Error retrying operation:", error);
return false;
}
},
[failedOperations, authState, isOnline],
);
// Retry all failed operations
const retryAllFailedOperations = useCallback(async (): Promise<number> => {
// Check if online before attempting retry
if (!isOnline) {
console.warn(
"[Synchronization] Cannot retry all operations: application is offline",
);
captureError(
ErrorType.NETWORK,
"Cannot retry all: application is offline",
new Error("Offline during retry attempt"),
{
context: "retryAllFailedOperations",
},
);
return 0;
}
// Validate token is available
if (!authState?.accessToken) {
console.error(
"[Synchronization] Cannot retry all operations: access token is missing or empty",
);
captureError(
ErrorType.AUTH,
"Cannot retry all: access token unavailable",
new Error("Cannot retry all: access token unavailable"),
{
context: "retryAllFailedOperations",
},
);
return 0;
}
const results = {
succeeded: 0,
failed: 0,
};
for (const operation of failedOperations) {
// Skip operations that have already exceeded max retries
if (operation.retryCount >= MAX_RETRY_ATTEMPTS) {
continue;
}
const success = await retryFailedOperation(operation.id);
if (success) {
results.succeeded += 1;
} else {
results.failed += 1;
}
}
// Show summary toast
if (results.succeeded > 0 || results.failed > 0) {
console.info(
`[Synchronization] Retry all complete: ${results.succeeded} succeeded, ${results.failed} failed`,
);
}
return results.succeeded;
}, [failedOperations, retryFailedOperation, isOnline, authState]);
// Clear a failed operation
const clearFailedOperation = useCallback((operationId: string): void => {
removeFailedOperation(operationId);
setFailedOperations((prev) => prev.filter((op) => op.id !== operationId));
}, []);
return [
state,
{
startSync,
cancelSync,
pauseSync,
resumeSync,
exportErrors,
exportReport,
reset,
failedOperations,
isLoadingFailedOps,
retryFailedOperation,
retryAllFailedOperations,
clearFailedOperation,
},
];
}
Provides state management and control methods for AniList synchronization operations. Supports batch sync, pause/resume recovery, incremental entry processing, and error tracking. Persists state to storage for recovery on app restart.