124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
import type { Card } from '@cardy/shared';
|
|
import type { Api } from './api.js';
|
|
import { cardImageIds, images, storage, 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<T>(items: readonly T[], limit: number, worker: (item: T) => Promise<void>): Promise<PromiseSettledResult<void>[]> {
|
|
const results: PromiseSettledResult<void>[] = 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<string>): Card[] {
|
|
return cards.filter(
|
|
(c) => versions[c.id] !== c.imagesVersion || cardImageIds(c).some((id) => !stored.has(images.key(c.id, id))),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Downloads the 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: Api): Promise<Card[]> {
|
|
const fresh = await api.cards();
|
|
const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
|
|
await storage.setCards(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(usage), storage.setVersions(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: Api,
|
|
cards: Card[],
|
|
onCardDone?: (card: Card) => void,
|
|
onProgress?: (done: number, total: number) => void,
|
|
): Promise<ImageSyncResult> {
|
|
const stored = await images.storedKeys();
|
|
const changed = cardsNeedingImages(cards, await storage.getVersions(), 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<string>();
|
|
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();
|
|
versions[card.id] = card.imagesVersion;
|
|
await storage.setVersions(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);
|
|
});
|
|
}
|