120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
import type { FeatureMeta, FeatureFilters } from '../types';
|
|
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
|
import pb from './pocketbase';
|
|
import { getSchoolBackendFeatureName } from './school-filter';
|
|
|
|
export function logNonAbortError(label: string, error: unknown): void {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
|
|
console.error(`${label}:`, error);
|
|
}
|
|
|
|
export function isAbortError(error: unknown): boolean {
|
|
return error instanceof Error && error.name === 'AbortError';
|
|
}
|
|
|
|
/** Throw if response is not 2xx. Call before `.json()`. */
|
|
export function assertOk(res: Response, label: string): void {
|
|
if (!res.ok) {
|
|
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}`);
|
|
}
|
|
}
|
|
|
|
export function authHeaders(init?: RequestInit): RequestInit {
|
|
const headers: Record<string, string> = {};
|
|
if (pb.authStore.isValid && pb.authStore.token) {
|
|
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
|
|
}
|
|
if (!init) return { headers };
|
|
const existing = init.headers as Record<string, string> | undefined;
|
|
return { ...init, headers: { ...existing, ...headers } };
|
|
}
|
|
|
|
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
|
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
|
const query = params?.toString();
|
|
return query ? `${path}?${query}` : path;
|
|
}
|
|
|
|
export async function fetchWithRetry<T>(
|
|
url: string,
|
|
onSuccess: (data: T) => void,
|
|
signal: AbortSignal
|
|
): Promise<void> {
|
|
let delay = INITIAL_RETRY_MS;
|
|
while (!signal.aborted) {
|
|
try {
|
|
const res = await fetch(url, authHeaders({ signal }));
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
onSuccess(json);
|
|
return;
|
|
} catch (err) {
|
|
if (signal.aborted) return;
|
|
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
delay = Math.min(delay * 2, MAX_RETRY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
|
export function prewarmScreenshot(params: string): void {
|
|
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
|
}
|
|
|
|
export async function shortenUrl(params: string): Promise<string> {
|
|
const res = await fetch(apiUrl('shorten'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ params }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
return `${window.location.origin}${data.url}`;
|
|
}
|
|
|
|
export function buildFilterString(
|
|
filters: FeatureFilters,
|
|
features: FeatureMeta[],
|
|
exclude?: string
|
|
): string {
|
|
const entries = Object.entries(filters);
|
|
if (entries.length === 0) return '';
|
|
|
|
const merged = new Map<string, [number, number] | string[]>();
|
|
for (const [name, value] of entries) {
|
|
if (name === exclude) continue;
|
|
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
|
const prev = merged.get(backendName);
|
|
if (
|
|
prev &&
|
|
Array.isArray(prev) &&
|
|
Array.isArray(value) &&
|
|
typeof prev[0] === 'number' &&
|
|
typeof value[0] === 'number'
|
|
) {
|
|
merged.set(backendName, [
|
|
Math.max(prev[0] as number, value[0] as number),
|
|
Math.min(prev[1] as number, value[1] as number),
|
|
]);
|
|
} else if (!prev) {
|
|
merged.set(backendName, value);
|
|
}
|
|
}
|
|
|
|
return [...merged.entries()]
|
|
.map(([name, value]) => {
|
|
const meta = features.find((f) => f.name === name);
|
|
if (meta?.type === 'enum') {
|
|
return `${name}:${(value as string[]).join('|')}`;
|
|
}
|
|
const [min, max] = value as [number, number];
|
|
const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
|
|
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
|
|
return `${name}:${min}:${maxStr}`;
|
|
})
|
|
.join(';;');
|
|
}
|