• Process a batch of manga updates with rate limiting and progress tracking.

    Parameters

    • entries: AniListMediaEntry[]

      Array of AniList media entries to sync.

    • token: string

      The user's authentication token.

    • OptionalonProgress: (progress: SyncProgress) => void

      Optional callback for progress updates.

    • OptionalabortSignal: AbortSignal

      Optional abort signal to cancel the sync.

    • OptionaldisplayOrderMediaIds: number[]

      Optional array of media IDs to control sync order.

    Returns Promise<SyncReport>

    A promise resolving to a SyncReport object.

    export async function syncMangaBatch(
    entries: AniListMediaEntry[],
    token: string,
    onProgress?: (progress: SyncProgress) => void,
    abortSignal?: AbortSignal,
    displayOrderMediaIds?: number[],
    ): Promise<SyncReport> {
    const results: SyncResult[] = [];
    const errors: { mediaId: number; error: string }[] = [];

    // Group entries by mediaId for handling incremental sync properly
    const entriesByMediaId: Record<number, AniListMediaEntry[]> = {};

    // Organize entries by mediaId
    entries.forEach((entry) => {
    if (entry.syncMetadata?.useIncrementalSync) {
    const steps = getIncrementalSteps(entry);
    for (const step of steps) {
    const stepEntry = { ...entry };
    stepEntry.syncMetadata = {
    ...entry.syncMetadata,
    step: step,
    };
    if (!entriesByMediaId[entry.mediaId]) {
    entriesByMediaId[entry.mediaId] = [];
    }
    entriesByMediaId[entry.mediaId].push(stepEntry);
    }
    } else {
    if (!entriesByMediaId[entry.mediaId]) {
    entriesByMediaId[entry.mediaId] = [];
    }
    entriesByMediaId[entry.mediaId].push(entry);
    }
    });

    // Use displayOrderMediaIds if provided, else fallback to Object.keys
    const userOrderMediaIds: number[] =
    displayOrderMediaIds && displayOrderMediaIds.length > 0
    ? displayOrderMediaIds
    : Object.keys(entriesByMediaId).map(Number);

    const progress: SyncProgress = {
    total: userOrderMediaIds.length,
    completed: 0,
    successful: 0,
    failed: 0,
    skipped: 0,
    currentEntry: null,
    currentStep: null,
    totalSteps: null,
    rateLimited: false,
    retryAfter: null,
    };

    if (onProgress) {
    onProgress({ ...progress });
    }

    let apiCallsCompleted = 0;

    // Track which manga have been completed in user order
    const completedMediaIds = new Set<number>();
    let completedCount = 0; // NEW: Track completed manga count

    for (const mediaIdNum of userOrderMediaIds) {
    const entriesForMediaId = entriesByMediaId[mediaIdNum];
    const mediaIdStr = String(mediaIdNum);
    if (!entriesForMediaId) continue; // skip if not present

    // DEBUG: Log start of manga sync
    console.log(
    `[SYNC] Starting manga ${mediaIdNum} (${completedCount + 1} of ${progress.total})`,
    );
    console.log(`[SYNC] userOrderMediaIds:`, userOrderMediaIds);
    console.log(`[SYNC] completedMediaIds:`, Array.from(completedMediaIds));

    if (abortSignal?.aborted) {
    console.log("Sync operation aborted by user");
    break;
    }

    entriesForMediaId.sort((a: AniListMediaEntry, b: AniListMediaEntry) => {
    const stepA = a.syncMetadata?.step || 0;
    const stepB = b.syncMetadata?.step || 0;
    return stepA - stepB;
    });

    const firstEntry = entriesForMediaId[0];
    const isIncremental =
    entriesForMediaId.length > 1 &&
    firstEntry.syncMetadata?.useIncrementalSync;

    progress.currentEntry = {
    mediaId: Number(mediaIdStr),
    title: firstEntry.title || `Manga #${mediaIdStr}`,
    coverImage: firstEntry.coverImage || "",
    };

    if (isIncremental) {
    progress.totalSteps = entriesForMediaId.length;
    } else {
    progress.currentStep = null;
    progress.totalSteps = null;
    }

    let entrySuccess = true;
    let entryError: string | undefined;

    for (let i = 0; i < entriesForMediaId.length; i++) {
    if (abortSignal?.aborted) {
    console.log("Sync operation aborted by user");
    break;
    }
    const entry = entriesForMediaId[i];
    if (isIncremental) {
    progress.currentStep = entry.syncMetadata?.step || i + 1;
    }
    if (onProgress) {
    // Do not update completed here; only after manga is finished
    // DEBUG: Log progress update
    console.log(
    `[PROGRESS] Updating progress: completed=${progress.completed}, total=${progress.total}, currentMediaId=${mediaIdNum}, currentStep=${progress.currentStep}, isIncremental=${isIncremental}`,
    );
    onProgress({ ...progress });
    }
    try {
    if (apiCallsCompleted > 0) {
    await new Promise((resolve) => setTimeout(resolve, REQUEST_INTERVAL));
    }
    const result = await updateMangaEntry(entry, token);
    results.push(result);
    apiCallsCompleted++;
    if (result.rateLimited && result.retryAfter) {
    progress.rateLimited = true;
    progress.retryAfter = result.retryAfter;
    if (onProgress) {
    console.log(
    `[PROGRESS] Rate limited: retryAfter=${result.retryAfter}`,
    );
    onProgress({ ...progress });
    }
    const retryAfterMs = result.retryAfter;
    const startTime = Date.now();
    const endTime = startTime + retryAfterMs;
    const countdownInterval = setInterval(() => {
    const currentTime = Date.now();
    const remainingMs = Math.max(0, endTime - currentTime);
    progress.retryAfter = remainingMs;
    if (onProgress) {
    onProgress({ ...progress });
    }
    if (remainingMs <= 0 || abortSignal?.aborted) {
    clearInterval(countdownInterval);
    }
    }, 1000);
    await new Promise((resolve) => {
    const timeoutId = setTimeout(() => {
    clearInterval(countdownInterval);
    progress.rateLimited = false;
    progress.retryAfter = null;
    if (onProgress) {
    onProgress({ ...progress });
    }
    resolve(null);
    }, retryAfterMs);
    if (abortSignal) {
    abortSignal.addEventListener("abort", () => {
    clearTimeout(timeoutId);
    clearInterval(countdownInterval);
    resolve(null);
    });
    }
    });
    i--;
    continue;
    }
    if (!result.success) {
    entrySuccess = false;
    entryError = result.error;
    if (isIncremental) break;
    }
    } catch (error) {
    apiCallsCompleted++;
    entrySuccess = false;
    entryError = error instanceof Error ? error.message : String(error);
    const errorOpId = `err-${mediaIdStr}-${entriesForMediaId[i].syncMetadata?.step || 0}-${Date.now().toString(36).substring(4, 10)}`;
    console.error(
    `❌ [${errorOpId}] Error updating entry ${mediaIdStr}:`,
    error,
    );
    console.error(` [${errorOpId}] Entry details:`, {
    mediaId: entriesForMediaId[i].mediaId,
    title: entriesForMediaId[i].title,
    status: entriesForMediaId[i].status,
    progress: entriesForMediaId[i].progress,
    score: entriesForMediaId[i].score,
    incremental: isIncremental,
    step: entriesForMediaId[i].syncMetadata?.step || "N/A",
    });
    if (isIncremental) break;
    }
    }
    // Mark this manga as completed in user order
    completedMediaIds.add(Number(mediaIdStr));
    completedCount++;
    progress.completed = completedCount;
    // DEBUG: Log after completing manga
    console.log(
    `[COMPLETE] Finished manga ${mediaIdNum}: completed=${progress.completed}, total=${progress.total}`,
    );
    if (entrySuccess) {
    progress.successful++;
    } else {
    progress.failed++;
    errors.push({
    mediaId: Number(mediaIdStr),
    error: entryError || "Unknown error",
    });
    }
    progress.currentEntry = null;
    progress.currentStep = null;
    if (onProgress) {
    onProgress({ ...progress });
    }
    }

    const report: SyncReport = {
    totalEntries: entries.length,
    successfulUpdates: progress.successful,
    failedUpdates: progress.failed,
    skippedEntries: progress.skipped,
    errors,
    timestamp: new Date(),
    };

    try {
    const prevStats = JSON.parse(
    storage.getItem(STORAGE_KEYS.SYNC_STATS) || "{}",
    );
    const totalSyncs = (prevStats.totalSyncs || 0) + 1;
    const syncStats = {
    lastSyncTime: report.timestamp,
    entriesSynced: report.successfulUpdates,
    failedSyncs: report.failedUpdates,
    totalSyncs,
    };
    storage.setItem(STORAGE_KEYS.SYNC_STATS, JSON.stringify(syncStats));
    } catch (e) {
    console.error("Failed to save sync stats:", e);
    }

    console.log("Sync completed:", report);
    return report;
    }