import type { Card } from '@cardy/shared'; import type { Api, GroupApi } from './api.js'; import { LEGACY_GROUP_ID, cardImageIds, images, storage, type Session, type UsageMap, type VersionMap } from './storage.js'; export const MAX_PARALLEL_DOWNLOADS = 5; /** Runs `worker` over `items` with at most `limit` running at once. */ export async function runPool(items: readonly T[], limit: number, worker: (item: T) => Promise): Promise[]> { const results: PromiseSettledResult[] = new Array(items.length); let next = 0; const lane = async () => { while (next < items.length) { const index = next++; try { await worker(items[index]!); results[index] = { status: 'fulfilled', value: undefined }; } catch (reason) { results[index] = { status: 'rejected', reason }; } } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane)); return results; } /** Cards whose images changed (other imagesVersion) or are not all stored locally. */ export function cardsNeedingImages(cards: Card[], versions: VersionMap, stored: Set): Card[] { return cards.filter( (c) => versions[c.id] !== c.imagesVersion || cardImageIds(c).some((id) => !stored.has(images.key(c.id, id))), ); } /** * Re-reads the key owner's groups from the server and returns the updated session: * - a legacy session (from before multi-group) gets its groups, and its local data moves to the first * group (the one the group-less routes served); * - local data of groups the user no longer belongs to is removed; * - the current group falls back to the first group when it is gone. */ export async function refreshGroups(api: Api, session: Session): Promise { const { groups } = await api.auth(); const firstId = groups[0]!.id; if (!session.groups?.length) { await storage.moveGroup(LEGACY_GROUP_ID, firstId); } else { for (const old of session.groups) if (!groups.some((g) => g.id === old.id)) await storage.removeGroup(old.id); } const current = groups.some((g) => g.id === session.currentGroupId) ? session.currentGroupId : firstId; const { groupName: _legacy, ...rest } = session; const next: Session = { ...rest, groups, currentGroupId: current }; await storage.setSession(next); return next; } /** * Downloads the group's list of cards and replaces the local list (only when the download succeeded). * Cards gone from the server are removed locally with their images and usage. */ export async function refreshCardList(api: GroupApi, groupId: string): Promise { const fresh = await api.cards(); const [old, usage, versions] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId), storage.getVersions(groupId)]); await storage.setCards(groupId, fresh); const freshIds = new Set(fresh.map((c) => c.id)); const gone = old.filter((c) => !freshIds.has(c.id)); if (gone.length) { for (const c of gone) { delete usage[c.id]; delete versions[c.id]; await images.prune(c.id); } await Promise.all([storage.setUsage(groupId, usage), storage.setVersions(groupId, versions)]); } return fresh; } export interface ImageSyncResult { cards: number; failedFiles: number; } /** * Downloads missing logos and images of changed cards, at most MAX_PARALLEL_DOWNLOADS requests at once. * A card's version is recorded only after all its files are stored, so failures are retried next time. */ export async function syncImages( api: GroupApi, groupId: string, cards: Card[], onCardDone?: (card: Card) => void, onProgress?: (done: number, total: number) => void, ): Promise { const stored = await images.storedKeys(); const changed = cardsNeedingImages(cards, await storage.getVersions(groupId), stored); const tasks = changed.flatMap((card) => cardImageIds(card) .filter((id) => !stored.has(images.key(card.id, id))) .map((id) => ({ card, id })), ); const remaining = new Map(changed.map((c) => [c.id, tasks.filter((t) => t.card === c).length])); const failedCards = new Set(); let done = 0; onProgress?.(0, tasks.length); // Version updates are read-modify-write: serialize them across parallel downloads. let versionWrites = Promise.resolve(); const finishCard = (card: Card) => { if (failedCards.has(card.id)) return versionWrites; versionWrites = versionWrites.then(async () => { await images.prune(card.id, cardImageIds(card)); const versions = await storage.getVersions(groupId); versions[card.id] = card.imagesVersion; await storage.setVersions(groupId, versions); onCardDone?.(card); }); return versionWrites; }; // Cards with nothing to download (e.g. only deletions) are complete right away. for (const card of changed) if (remaining.get(card.id) === 0) await finishCard(card); const results = await runPool(tasks, MAX_PARALLEL_DOWNLOADS, async ({ card, id }) => { try { await images.put(card.id, id, await api.file(card, id)); } catch (e) { failedCards.add(card.id); throw e; } finally { onProgress?.(++done, tasks.length); const left = (remaining.get(card.id) ?? 1) - 1; remaining.set(card.id, left); if (left === 0) await finishCard(card); } }); return { cards: changed.length, failedFiles: results.filter((r) => r.status === 'rejected').length }; } /** Most recently used first, never used cards by name. */ export function sortCards(cards: Card[], usage: UsageMap): Card[] { return [...cards].sort((a, b) => { const ua = usage[a.id] ?? ''; const ub = usage[b.id] ?? ''; if (ua !== ub) return ua < ub ? 1 : -1; return a.name.localeCompare(b.name); }); }