pwa and mobile client

This commit is contained in:
azykov@mail.ru
2026-09-29 15:55:25 +03:00
parent 657009bd6c
commit 1935acf2ff
96 changed files with 8370 additions and 58 deletions
+123
View File
@@ -0,0 +1,123 @@
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);
});
}