A tuple containing the synchronization state and an object of 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 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 } = useDebug();
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 = cloneEntries(
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: 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 = pendingEntries;
saveSnapshotToStorage(snapshot);
setState((prev) => ({
...prev,
resumeAvailable: true,
resumeMetadata: {
remainingMediaIds,
timestamp: snapshot.timestamp,
},
}));
emitSyncSnapshot();
};
const clearResumeSnapshot = () => {
resumeSnapshotRef.current = null;
initialEntriesRef.current = [];
uniqueMediaIdsRef.current = [];
saveSnapshotToStorage(null);
setState((prev) => ({
...prev,
resumeAvailable: false,
resumeMetadata: null,
}));
emitSyncSnapshot();
};
useEffect(() => {
if (hasLoadedSnapshotRef.current) return;
hasLoadedSnapshotRef.current = true;
const storedSnapshot = storage.getItem(SNAPSHOT_STORAGE_KEY);
if (!storedSnapshot) return;
try {
const parsed: SyncResumeSnapshot = JSON.parse(storedSnapshot);
if (!parsed?.remainingMediaIds?.length) {
storage.removeItem(SNAPSHOT_STORAGE_KEY);
return;
}
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("Failed to load stored sync snapshot:", error);
storage.removeItem(SNAPSHOT_STORAGE_KEY);
}
}, []);
/**
* Starts a synchronization operation for the provided AniList media entries.
*
* @param entries - The AniList media entries to synchronize.
* @param token - The AniList authentication token.
* @param _unused - (Unused) Reserved for future use.
* @param displayOrderMediaIds - Optional array of media IDs to control display order.
* @returns A promise that resolves when synchronization is complete.
* @throws If the sync operation fails or is aborted.
* @source
*/
const startSync = useCallback(
async (
entries: AniListMediaEntry[],
token: string,
_unused?: undefined,
displayOrderMediaIds?: number[],
) => {
if (state.isActive) {
console.warn("Sync is already in progress");
return;
}
if (!entries.length) {
setState((prev) => ({ ...prev, error: "No entries to synchronize" }));
return;
}
if (!token) {
setState((prev) => ({
...prev,
error: "No authentication token available",
}));
return;
}
const isResume = resumeRequestedRef.current;
resumeRequestedRef.current = false;
if (!isResume && resumeSnapshotRef.current) clearResumeSnapshot();
const existingReportFragment = isResume
? fromPersistedReport(resumeSnapshotRef.current?.reportFragment ?? null)
: null;
pauseRequestedRef.current = false;
// Initialize controller, ids, progress
const initRun = (): {
abortController: AbortController;
uniqueMediaIds: number[];
lastProgress: SyncProgress;
} => {
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);
return {
abortController,
uniqueMediaIds,
lastProgress: initialProgress,
};
};
// Run a regular batch and return report
const runRegularBatch = async (
batchEntries: AniListMediaEntry[],
tokenArg: string,
abortSignal: AbortSignal,
uniqueIds: number[],
onProgressUpdate: (p: SyncProgress) => void,
): Promise<SyncReport> => {
if (batchEntries.length === 0) {
return {
totalEntries: 0,
successfulUpdates: 0,
failedUpdates: 0,
skippedEntries: 0,
errors: [],
timestamp: new Date(),
};
}
return await syncMangaBatch(
batchEntries,
tokenArg,
(progress) => {
const normalized: SyncProgress = {
...progress,
total: uniqueIds.length,
};
onProgressUpdate(normalized);
},
abortSignal,
uniqueIds,
);
};
// Process incremental entries sequentially and merge into report
const runIncrementalEntries = async (
incEntries: AniListMediaEntry[],
tokenArg: string,
abortSignal: AbortSignal,
uniqueIds: number[],
initialPartialReport: SyncReport,
onProgressUpdate: (p: SyncProgress) => void,
): Promise<SyncReport> => {
// Start from an initial partial report
let successfulUpdates = initialPartialReport.successfulUpdates;
let failedUpdates = initialPartialReport.failedUpdates;
let skippedUpdates = initialPartialReport.skippedEntries;
let overallProgress =
successfulUpdates + failedUpdates + skippedUpdates;
const errors = [...initialPartialReport.errors];
let lastReportedProgress: SyncProgress = {
total: uniqueIds.length,
completed: overallProgress,
successful: successfulUpdates,
failed: failedUpdates,
skipped: skippedUpdates,
currentEntry: null,
currentStep: null,
totalSteps: null,
rateLimited: false,
retryAfter: null,
};
const updateProgressLocal = (
entry: AniListMediaEntry,
step: number | null = null,
stepCompleted = false,
success = true,
) => {
if (stepCompleted) {
overallProgress++;
if (success) successfulUpdates++;
else failedUpdates++;
}
const next: SyncProgress = {
...lastReportedProgress,
completed: overallProgress,
total: uniqueIds.length,
successful: successfulUpdates,
failed: failedUpdates,
skipped: skippedUpdates,
currentEntry: {
mediaId: entry.mediaId,
title: entry.title || `Manga #${entry.mediaId}`,
coverImage: entry.coverImage || "",
},
currentStep: step,
totalSteps: entry.syncMetadata?.useIncrementalSync ? 3 : null,
rateLimited: false,
retryAfter: null,
};
lastReportedProgress = next;
setState((prev) => ({ ...prev, progress: next }));
updateSnapshotFromProgress(next, initialEntriesRef.current);
onProgressUpdate(next);
};
for (const entry of incEntries) {
if (abortSignal.aborted) {
console.log("Incremental sync cancelled - stopping processing");
break;
}
try {
const syncResult = await syncMangaBatch(
[entry],
tokenArg,
(progress) => {
if (progress.currentEntry) {
updateProgressLocal(
entry,
progress.currentStep ?? null,
false,
true,
);
}
},
abortSignal,
uniqueIds,
);
if (abortSignal.aborted) {
console.log(
`Sync aborted during processing of entry ${entry.mediaId}`,
);
break;
}
successfulUpdates += syncResult.successfulUpdates;
failedUpdates += syncResult.failedUpdates;
skippedUpdates += syncResult.skippedEntries;
overallProgress += syncResult.totalEntries;
if (syncResult.errors.length) errors.push(...syncResult.errors);
const finalized: SyncProgress = {
...lastReportedProgress,
completed: overallProgress,
total: uniqueIds.length,
successful: successfulUpdates,
failed: failedUpdates,
skipped: skippedUpdates,
currentEntry: lastReportedProgress.currentEntry,
currentStep: null,
totalSteps: lastReportedProgress.totalSteps,
rateLimited: false,
retryAfter: null,
};
lastReportedProgress = finalized;
setState((prev) => ({ ...prev, progress: finalized }));
updateSnapshotFromProgress(finalized, initialEntriesRef.current);
console.log(`Completed all steps for ${entry.mediaId}`);
} catch (err) {
console.error(
`Error processing incremental sync for entry ${entry.mediaId}:`,
err,
);
failedUpdates++;
overallProgress++;
if (err instanceof Error) {
errors.push({ mediaId: entry.mediaId, error: err.message });
}
const failedProgress: SyncProgress = {
...lastReportedProgress,
completed: overallProgress,
total: uniqueIds.length,
successful: successfulUpdates,
failed: failedUpdates,
skipped: skippedUpdates,
currentEntry: lastReportedProgress.currentEntry,
currentStep: null,
totalSteps: lastReportedProgress.totalSteps,
rateLimited: false,
retryAfter: null,
};
lastReportedProgress = failedProgress;
setState((prev) => ({ ...prev, progress: failedProgress }));
updateSnapshotFromProgress(
failedProgress,
initialEntriesRef.current,
);
// continue to next entry
}
}
return {
successfulUpdates,
failedUpdates,
totalEntries: successfulUpdates + failedUpdates + skippedUpdates,
skippedEntries: skippedUpdates,
errors,
timestamp: new Date(),
};
};
// Main orchestration
try {
const { abortController, uniqueMediaIds, lastProgress } = initRun();
let lastReportedProgress = lastProgress;
let syncReport: SyncReport;
const needsIncrementalSync = entries.some(
(e) => e.syncMetadata?.useIncrementalSync,
);
if (needsIncrementalSync) {
console.log("Using sequential incremental sync mode");
const incrementalEntries = entries
.filter((e) => e.syncMetadata?.useIncrementalSync)
.map((entry) => {
const previousProgress = entry.previousValues?.progress || 0;
const targetProgress = entry.progress;
return {
...entry,
syncMetadata: {
...entry.syncMetadata!,
targetProgress,
progress: previousProgress,
updatedStatus: entry.status !== entry.previousValues?.status,
updatedScore: entry.score !== entry.previousValues?.score,
step: undefined,
},
};
});
const regularEntries = entries.filter(
(e) => !e.syncMetadata?.useIncrementalSync,
);
console.log(
`Processing ${incrementalEntries.length} entries with incremental sync`,
);
console.log(
`Processing ${regularEntries.length} entries with regular sync`,
);
// Process regular batch first
syncReport = await runRegularBatch(
regularEntries,
token,
abortController.signal,
uniqueMediaIds,
(progress) => {
lastReportedProgress = progress;
setState((prev) => ({ ...prev, progress }));
updateSnapshotFromProgress(progress, initialEntriesRef.current);
},
);
// Then incremental entries sequentially
const incReport = await runIncrementalEntries(
incrementalEntries,
token,
abortController.signal,
uniqueMediaIds,
syncReport,
() => {
/* no-op: runIncrementalEntries updates state directly */
},
);
syncReport = incReport;
console.log("Incremental sync completed successfully");
} else {
console.log("Using standard (non-incremental) sync mode");
syncReport = await runRegularBatch(
entries,
token,
abortController.signal,
uniqueMediaIds,
(progress) => {
const normalized: SyncProgress = {
...progress,
total: uniqueMediaIds.length,
};
lastReportedProgress = normalized;
setState((prev) => ({ ...prev, progress: normalized }));
updateSnapshotFromProgress(normalized, initialEntriesRef.current);
},
);
}
// If pause was requested, persist partial report and snapshot
if (pauseRequestedRef.current) {
const partialReport = mergeReports(
existingReportFragment,
syncReport,
);
if (!resumeSnapshotRef.current) {
updateSnapshotFromProgress(
lastReportedProgress,
initialEntriesRef.current,
);
}
if (resumeSnapshotRef.current) {
resumeSnapshotRef.current.reportFragment =
toPersistedReport(partialReport);
resumeSnapshotRef.current.progress = { ...lastReportedProgress };
resumeSnapshotRef.current.timestamp = Date.now();
saveSnapshotToStorage(resumeSnapshotRef.current);
}
setState((prev) => ({
...prev,
isActive: false,
isPaused: true,
report: partialReport,
error: null,
abortController: null,
resumeAvailable: true,
resumeMetadata: resumeSnapshotRef.current
? {
remainingMediaIds:
resumeSnapshotRef.current.remainingMediaIds,
timestamp: resumeSnapshotRef.current.timestamp,
}
: prev.resumeMetadata,
}));
return;
}
const finalReport = mergeReports(existingReportFragment, syncReport);
if (!pauseRequestedRef.current) {
saveSyncReportToHistory(finalReport);
clearResumeSnapshot();
}
setState((prev) => ({
...prev,
isActive: false,
isPaused: false,
report: finalReport,
abortController: null,
resumeAvailable: false,
resumeMetadata: null,
}));
} catch (error) {
console.error("Sync operation failed:", error);
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 operation, aborting all in-progress requests.
*
* @remarks
* If no synchronization is active, this function does nothing.
*
* @source
*/
const cancelSync = useCallback(() => {
if (state.abortController) {
console.log("Cancellation requested - aborting all sync operations");
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.log(
"%c🛑 SYNC CANCELLED - All operations stopped",
"color: red; font-weight: bold",
);
}
}, [clearResumeSnapshot, state.abortController]);
const pauseSync = useCallback(() => {
if (state.abortController) {
console.log("Pause requested - stopping current sync after current task");
pauseRequestedRef.current = true;
state.abortController.abort();
emitSyncSnapshot();
}
}, [state.abortController, emitSyncSnapshot]);
const resumeSync = useCallback(
async (
entries: AniListMediaEntry[],
token: string,
_unused?: undefined,
displayOrderMediaIds?: number[],
) => {
const snapshot = resumeSnapshotRef.current;
if (!snapshot) {
console.warn("No paused synchronization state available to resume.");
return;
}
const remainingIds = snapshot.remainingMediaIds;
if (!remainingIds || remainingIds.length === 0) {
console.warn("Paused state contains no remaining entries.");
clearResumeSnapshot();
return;
}
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("No matching entries found to resume.");
clearResumeSnapshot();
return;
}
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 to a file.
*
* @remarks
* If no report is available, this function does nothing.
*
* @source
*/
const exportErrors = useCallback(() => {
if (state.report) {
exportSyncErrorLog(state.report);
}
}, [state.report]);
/**
* Exports the full synchronization report to a file.
*
* @remarks
* If no report is available, this function does nothing.
*
* @source
*/
const exportReport = useCallback(() => {
if (state.report) {
exportSyncReport(state.report);
}
}, [state.report]);
/**
* Resets the synchronization state to its initial values.
*
* @source
*/
const reset = useCallback(() => {
clearResumeSnapshot();
setState({
isActive: false,
progress: null,
report: null,
error: null,
abortController: null,
isPaused: false,
resumeAvailable: false,
resumeMetadata: null,
});
emitSyncSnapshot();
}, [clearResumeSnapshot, emitSyncSnapshot]);
return [
state,
{
startSync,
cancelSync,
pauseSync,
resumeSync,
exportErrors,
exportReport,
reset,
},
];
}
Hook that provides methods and state for managing AniList synchronization.