693 lines
22 KiB
TypeScript
693 lines
22 KiB
TypeScript
/**
|
|
* Shared utilities for PlebSaber tools
|
|
*/
|
|
|
|
// ============================================================================
|
|
// 1. Type Definitions
|
|
// ============================================================================
|
|
|
|
export type MapMeta = {
|
|
songName?: string;
|
|
key?: string;
|
|
coverURL?: string;
|
|
mapper?: string;
|
|
score?: number;
|
|
};
|
|
|
|
export type StarInfo = {
|
|
stars?: number;
|
|
accRating?: number;
|
|
passRating?: number;
|
|
techRating?: number;
|
|
status?: number;
|
|
};
|
|
|
|
export type BeatLeaderScore = {
|
|
timeset?: string | number;
|
|
accuracy?: number;
|
|
acc?: number;
|
|
rank?: number;
|
|
leaderboard?: {
|
|
id?: string | number | null;
|
|
leaderboardId?: string | number | null;
|
|
song?: { hash?: string | null };
|
|
difficulty?: { value?: number | string | null; modeName?: string | null };
|
|
};
|
|
};
|
|
|
|
export type BeatLeaderScoresResponse = {
|
|
data?: BeatLeaderScore[];
|
|
metadata?: { page?: number; itemsPerPage?: number; total?: number };
|
|
};
|
|
|
|
export type BeatLeaderPlayerProfile = {
|
|
id?: string;
|
|
name?: string;
|
|
avatar?: string | null;
|
|
country?: string | null;
|
|
rank?: number | null;
|
|
countryRank?: number | null;
|
|
techPp?: number | null;
|
|
accPp?: number | null;
|
|
passPp?: number | null;
|
|
};
|
|
|
|
export type RequirementContext = {
|
|
adminRank?: number | null;
|
|
};
|
|
|
|
export type ToolRequirement = {
|
|
minGlobalRank?: number;
|
|
requiresBetterRankThanAdmin?: boolean;
|
|
summary: string;
|
|
lockedMessage?: string;
|
|
};
|
|
|
|
export type Difficulty = {
|
|
name: string;
|
|
characteristic: string;
|
|
};
|
|
|
|
export type TriangleCorners = {
|
|
tech: { x: number; y: number };
|
|
acc: { x: number; y: number };
|
|
pass: { x: number; y: number };
|
|
};
|
|
|
|
export type TriangleNormalized = {
|
|
tech: number;
|
|
acc: number;
|
|
pass: number;
|
|
};
|
|
|
|
export type TriangleData = {
|
|
corners: TriangleCorners;
|
|
normalized: TriangleNormalized;
|
|
} | null;
|
|
|
|
// ============================================================================
|
|
// 2. Constants
|
|
// ============================================================================
|
|
|
|
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
export const PLEB_BEATLEADER_ID = '76561199407393962';
|
|
export const DEFAULT_ADMIN_RANK_FALLBACK = 3000;
|
|
|
|
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
|
|
|
|
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
|
|
|
|
const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
|
requiresBetterRankThanAdmin: true,
|
|
summary: 'Ranked higher than pleb on BeatLeader',
|
|
lockedMessage: 'You must either contribute to BL Patreon (or be ranked higher than pleb) to use this tool'
|
|
};
|
|
|
|
export const TOOL_REQUIREMENTS = {
|
|
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
|
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
|
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
|
'playlist-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
|
} as const satisfies Record<string, ToolRequirement>;
|
|
|
|
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
|
|
|
export function getToolRequirement(key: string): ToolRequirement | null {
|
|
return TOOL_REQUIREMENTS[key as ToolKey] ?? null;
|
|
}
|
|
|
|
export function meetsToolRequirement(
|
|
profile: BeatLeaderPlayerProfile | null | undefined,
|
|
requirement: ToolRequirement | null | undefined,
|
|
context?: RequirementContext
|
|
): boolean {
|
|
if (!requirement) return true;
|
|
const rank = profile?.rank ?? null;
|
|
|
|
if (requirement.requiresBetterRankThanAdmin) {
|
|
const baseline = resolveAdminBaseline(context);
|
|
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
|
|
return rank <= (baseline ?? DEFAULT_ADMIN_RANK_FALLBACK);
|
|
}
|
|
|
|
if (requirement.minGlobalRank !== undefined) {
|
|
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
|
|
return rank <= requirement.minGlobalRank;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function resolveAdminBaseline(context?: RequirementContext): number | null {
|
|
const val = context?.adminRank;
|
|
if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
|
|
return val;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function formatToolRequirementSummary(
|
|
requirement: ToolRequirement | null | undefined,
|
|
context?: RequirementContext
|
|
): string {
|
|
if (!requirement) return '';
|
|
if (requirement.requiresBetterRankThanAdmin) {
|
|
const baseline = resolveAdminBaseline(context);
|
|
if (baseline) {
|
|
return `BeatLeader global rank ≤ ${baseline.toLocaleString()} (pleb's current rank)`;
|
|
}
|
|
return requirement.summary || `BeatLeader global rank ≤ ${DEFAULT_ADMIN_RANK_FALLBACK.toLocaleString()}`;
|
|
}
|
|
if (requirement.summary) return requirement.summary;
|
|
if (requirement.minGlobalRank !== undefined) {
|
|
return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ============================================================================
|
|
// 3. BeatSaver & BeatLeader API Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Fetch BeatSaver metadata for a given song hash
|
|
*/
|
|
export async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
|
try {
|
|
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
const data: any = await res.json();
|
|
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
|
|
return {
|
|
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
|
key: data?.id ?? undefined,
|
|
coverURL: cover,
|
|
mapper: data?.uploader?.name ?? undefined,
|
|
score: typeof data?.stats?.score === 'number' ? data.stats.score : undefined
|
|
};
|
|
} catch {
|
|
// Fallback to CDN cover only
|
|
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch BeatLeader star ratings for a given song hash
|
|
* Returns a map keyed by `${hash}|${difficultyName}|${modeName}`
|
|
*/
|
|
export async function fetchBeatLeaderStarsByHash(
|
|
hash: string,
|
|
normalizeDifficultyName: (value: number | string | null | undefined) => string
|
|
): Promise<Record<string, StarInfo>> {
|
|
try {
|
|
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
|
|
if (!res.ok) return {};
|
|
const data: any = await res.json();
|
|
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
|
|
const result: Record<string, StarInfo> = {};
|
|
for (const lb of leaderboards) {
|
|
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
|
|
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
|
|
if (!diffName || !modeName) continue;
|
|
const normalized = normalizeDifficultyName(diffName);
|
|
const key = `${hash}|${normalized}|${modeName}`;
|
|
const info: StarInfo = {
|
|
stars: lb?.difficulty?.stars ?? lb?.stars,
|
|
accRating: lb?.difficulty?.accRating,
|
|
passRating: lb?.difficulty?.passRating,
|
|
techRating: lb?.difficulty?.techRating,
|
|
status: lb?.difficulty?.status
|
|
};
|
|
result[key] = info;
|
|
}
|
|
return result;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load metadata for a list of items with unique hashes
|
|
* Only loads metadata that isn't already in the cache
|
|
*/
|
|
export async function loadMetaForHashes(
|
|
hashes: string[],
|
|
existingCache: Record<string, MapMeta>,
|
|
onProgress?: (loaded: number, total: number) => void
|
|
): Promise<Record<string, MapMeta>> {
|
|
const uniqueHashes = Array.from(new Set(hashes));
|
|
const needed = uniqueHashes.filter((h) => !existingCache[h]);
|
|
if (needed.length === 0) return existingCache;
|
|
|
|
const newCache = { ...existingCache };
|
|
for (let i = 0; i < needed.length; i++) {
|
|
const h = needed[i];
|
|
const meta = await fetchBeatSaverMeta(h);
|
|
if (meta) newCache[h] = meta;
|
|
if (onProgress) onProgress(i + 1, needed.length);
|
|
}
|
|
return newCache;
|
|
}
|
|
|
|
/**
|
|
* Load star ratings for a list of hashes
|
|
* Only loads ratings that aren't already in the cache
|
|
*/
|
|
export async function loadStarsForHashes(
|
|
hashes: string[],
|
|
existingCache: Record<string, StarInfo>,
|
|
normalizeFn: (value: number | string | null | undefined) => string,
|
|
onProgress?: (loaded: number, total: number) => void
|
|
): Promise<Record<string, StarInfo>> {
|
|
const uniqueHashes = Array.from(new Set(hashes));
|
|
// Check if we need to load stars for these hashes
|
|
const needed = uniqueHashes.filter((h) => {
|
|
// Check if we have any star data for this hash
|
|
return !Object.keys(existingCache).some(key => key.startsWith(`${h}|`));
|
|
});
|
|
|
|
if (needed.length === 0) return existingCache;
|
|
|
|
const newCache = { ...existingCache };
|
|
for (let i = 0; i < needed.length; i++) {
|
|
const h = needed[i];
|
|
const stars = await fetchBeatLeaderStarsByHash(h, normalizeFn);
|
|
Object.assign(newCache, stars);
|
|
if (onProgress) onProgress(i + 1, needed.length);
|
|
}
|
|
return newCache;
|
|
}
|
|
|
|
/**
|
|
* Fetch recent scores for a player filtered by difficulty
|
|
*/
|
|
export async function fetchAllRecentScoresForDiff(
|
|
playerId: string,
|
|
cutoffEpoch: number,
|
|
reqDiff: string,
|
|
maxPages = 100
|
|
): Promise<BeatLeaderScore[]> {
|
|
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
|
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
|
|
const data = (await res.json()) as BeatLeaderScoresResponse;
|
|
return data.data ?? [];
|
|
}
|
|
|
|
/**
|
|
* Fetch recent scores for a player across all difficulties
|
|
*/
|
|
export async function fetchAllRecentScoresAllDiffs(
|
|
playerId: string,
|
|
cutoffEpoch: number,
|
|
normalizeFn: (value: number | string | null | undefined) => string,
|
|
parseFn: (ts: string | number | undefined) => number
|
|
): Promise<BeatLeaderScore[]> {
|
|
const arrays = await Promise.all(
|
|
DIFFICULTIES.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
|
|
);
|
|
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
|
|
const merged = new Map<string, BeatLeaderScore>();
|
|
for (const arr of arrays) {
|
|
for (const s of arr) {
|
|
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
|
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
if (!rawHash) continue;
|
|
const hashLower = String(rawHash).toLowerCase();
|
|
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
|
|
const key = `${hashLower}|${diffName}|${modeName}`;
|
|
const prev = merged.get(key);
|
|
if (!prev || parseFn(prev.timeset) < parseFn(s.timeset)) merged.set(key, s);
|
|
}
|
|
}
|
|
return Array.from(merged.values());
|
|
}
|
|
|
|
/**
|
|
* Fetch all scores for a player (no time limit) by paginating through the BeatLeader API
|
|
* Useful for playlist gap analysis where we need to check all historical plays
|
|
*/
|
|
export async function fetchAllPlayerScores(playerId: string, maxPages = 200): Promise<BeatLeaderScore[]> {
|
|
const pageSize = 100;
|
|
let page = 1;
|
|
const all: BeatLeaderScore[] = [];
|
|
while (page <= maxPages) {
|
|
const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
|
|
const data = (await res.json()) as BeatLeaderScoresResponse;
|
|
const batch = data.data ?? [];
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
page += 1;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 4. Data Processing Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Normalize difficulty names to standard format
|
|
*/
|
|
export function normalizeDifficultyName(value: number | string | null | undefined): string {
|
|
if (value === null || value === undefined) return 'ExpertPlus';
|
|
if (typeof value === 'string') {
|
|
const v = value.toLowerCase();
|
|
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
|
|
if (v.includes('expert')) return 'Expert';
|
|
if (v.includes('hard')) return 'Hard';
|
|
if (v.includes('normal')) return 'Normal';
|
|
if (v.includes('easy')) return 'Easy';
|
|
return value;
|
|
}
|
|
switch (value) {
|
|
case 1: return 'Easy';
|
|
case 3: return 'Normal';
|
|
case 5: return 'Hard';
|
|
case 7: return 'Expert';
|
|
case 9: return 'ExpertPlus';
|
|
default: return 'ExpertPlus';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a timeset value to a number
|
|
*/
|
|
export function parseTimeset(ts: string | number | undefined): number {
|
|
if (ts === undefined) return 0;
|
|
if (typeof ts === 'number') return ts;
|
|
const n = Number(ts);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
/**
|
|
* Get cutoff epoch timestamp from months ago
|
|
*/
|
|
export function getCutoffEpochFromMonths(months: number | string): number {
|
|
const m = Number(months) || 0;
|
|
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
|
|
return Math.floor(Date.now() / 1000) - seconds;
|
|
}
|
|
|
|
/**
|
|
* Normalize accuracy value to percentage (0-100)
|
|
*/
|
|
export function normalizeAccuracy(value: number | undefined): number | null {
|
|
if (value === undefined || value === null) return null;
|
|
return value <= 1 ? value * 100 : value;
|
|
}
|
|
|
|
/**
|
|
* Build a map of latest scores by key (hash|diff|mode)
|
|
*/
|
|
export function buildLatestByKey(
|
|
scores: BeatLeaderScore[],
|
|
cutoffEpoch: number,
|
|
normalizeFn: (value: number | string | null | undefined) => string,
|
|
parseFn: (ts: string | number | undefined) => number
|
|
): Map<string, BeatLeaderScore> {
|
|
const byKey = new Map<string, BeatLeaderScore>();
|
|
for (const s of scores) {
|
|
const t = parseFn(s.timeset);
|
|
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
|
|
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
|
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
if (!rawHash) continue;
|
|
const hashLower = String(rawHash).toLowerCase();
|
|
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
|
|
const key = `${hashLower}|${diffName}|${modeName}`;
|
|
const prev = byKey.get(key);
|
|
if (!prev || parseFn(prev.timeset) < t) byKey.set(key, s);
|
|
}
|
|
return byKey;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 5. Statistical Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Calculate the mean (average) of an array of numbers
|
|
*/
|
|
export function mean(values: number[]): number {
|
|
if (!values.length) return 0;
|
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
}
|
|
|
|
/**
|
|
* Calculate the median of an array of numbers
|
|
*/
|
|
export function median(values: number[]): number {
|
|
if (!values.length) return 0;
|
|
const v = [...values].sort((a, b) => a - b);
|
|
const mid = Math.floor(v.length / 2);
|
|
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
|
|
}
|
|
|
|
/**
|
|
* Calculate a percentile of an array of numbers
|
|
*/
|
|
export function percentile(values: number[], p: number): number {
|
|
if (!values.length) return 0;
|
|
const v = [...values].sort((a, b) => a - b);
|
|
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
|
|
return v[idx];
|
|
}
|
|
|
|
// ============================================================================
|
|
// 6. Skill Triangle Calculations
|
|
// ============================================================================
|
|
|
|
const DEFAULT_MAX_TECH_PP = 1300;
|
|
const DEFAULT_MAX_ACC_PP = 15000;
|
|
const DEFAULT_MAX_PASS_PP = 6000;
|
|
const GYRON_LENGTH = 57.74;
|
|
|
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
|
|
/**
|
|
* Calculate skill triangle data from player's PP values
|
|
* Returns corner coordinates and normalized values for rendering the skill triangle
|
|
*/
|
|
export function calculateSkillTriangle(
|
|
techPp: number | null | undefined,
|
|
accPp: number | null | undefined,
|
|
passPp: number | null | undefined
|
|
): TriangleData {
|
|
const tech = typeof techPp === 'number' ? techPp : 0;
|
|
const acc = typeof accPp === 'number' ? accPp : 0;
|
|
const pass = typeof passPp === 'number' ? passPp : 0;
|
|
|
|
// If all values are zero, return null
|
|
if (tech === 0 && acc === 0 && pass === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate triangle scale
|
|
const triangleScale = Math.max(
|
|
1,
|
|
tech > 0 ? tech / DEFAULT_MAX_TECH_PP : 0,
|
|
acc > 0 ? acc / DEFAULT_MAX_ACC_PP : 0,
|
|
pass > 0 ? pass / DEFAULT_MAX_PASS_PP : 0
|
|
);
|
|
|
|
const maxTechPp = DEFAULT_MAX_TECH_PP * triangleScale;
|
|
const maxAccPp = DEFAULT_MAX_ACC_PP * triangleScale;
|
|
const maxPassPp = DEFAULT_MAX_PASS_PP * triangleScale;
|
|
|
|
// Normalize PP values
|
|
const normalizedTechPp = maxTechPp ? clamp(tech / maxTechPp, 0, 1) : 0;
|
|
const normalizedAccPp = maxAccPp ? clamp(acc / maxAccPp, 0, 1) : 0;
|
|
const normalizedPassPp = maxPassPp ? clamp(pass / maxPassPp, 0, 1) : 0;
|
|
|
|
// Calculate corner positions
|
|
const cornerTech = {
|
|
x: (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) * 0.866,
|
|
y: 86.6 - (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) / 2
|
|
};
|
|
const cornerAcc = {
|
|
x: 100 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) * 0.866,
|
|
y: 86.6 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) / 2
|
|
};
|
|
const cornerPass = {
|
|
x: 50,
|
|
y: (86.6 - GYRON_LENGTH / 2) * (1 - normalizedPassPp)
|
|
};
|
|
|
|
return {
|
|
corners: {
|
|
tech: cornerTech,
|
|
acc: cornerAcc,
|
|
pass: cornerPass
|
|
},
|
|
normalized: {
|
|
tech: normalizedTechPp,
|
|
acc: normalizedAccPp,
|
|
pass: normalizedPassPp
|
|
}
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// 7. Playlist Generation
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Increment and return the playlist count for a given key
|
|
*/
|
|
export function incrementPlaylistCount(key: string): number {
|
|
try {
|
|
const raw = localStorage.getItem('playlist_counts');
|
|
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
const next = (obj[key] ?? 0) + 1;
|
|
obj[key] = next;
|
|
localStorage.setItem('playlist_counts', JSON.stringify(obj));
|
|
return next;
|
|
} catch {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a list of songs to Beat Saber playlist JSON format
|
|
*/
|
|
export function toPlaylistJson(
|
|
songs: Array<{ hash: string; difficulties: Difficulty[] }>,
|
|
playlistKey: string,
|
|
description: string
|
|
): unknown {
|
|
const count = incrementPlaylistCount(playlistKey);
|
|
const playlistTitle = `${playlistKey}-${String(count).padStart(2, '0')}`;
|
|
return {
|
|
playlistTitle,
|
|
playlistAuthor: 'SaberList Tool',
|
|
songs: songs.map((s) => ({
|
|
hash: s.hash,
|
|
difficulties: s.difficulties,
|
|
})),
|
|
description,
|
|
allowDuplicates: false,
|
|
customData: {}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Download a playlist as a .bplist file
|
|
*/
|
|
export function downloadPlaylist(playlistData: unknown): void {
|
|
const title = (playlistData as any).playlistTitle ?? 'playlist';
|
|
const blob = new Blob([JSON.stringify(playlistData, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${title}.bplist`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 8. Pagination Utilities
|
|
// ============================================================================
|
|
|
|
export type PaginationResult<T> = {
|
|
pageItems: T[];
|
|
totalPages: number;
|
|
validPage: number;
|
|
};
|
|
|
|
/**
|
|
* Calculate pagination for a list of items
|
|
*/
|
|
export function calculatePagination<T>(
|
|
items: T[],
|
|
page: number,
|
|
pageSize: number
|
|
): PaginationResult<T> {
|
|
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
|
const validPage = Math.min(Math.max(1, page), totalPages);
|
|
const startIdx = (validPage - 1) * pageSize;
|
|
const endIdx = startIdx + pageSize;
|
|
const pageItems = items.slice(startIdx, endIdx);
|
|
|
|
return {
|
|
pageItems,
|
|
totalPages,
|
|
validPage
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// 9. URL Parameter Utilities
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse URL search parameters with type safety
|
|
*/
|
|
export function getUrlParam(
|
|
searchParams: URLSearchParams,
|
|
key: string,
|
|
defaultValue?: string
|
|
): string | undefined {
|
|
return searchParams.get(key) ?? defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Parse a URL parameter as a number
|
|
*/
|
|
export function getUrlParamAsNumber(
|
|
searchParams: URLSearchParams,
|
|
key: string,
|
|
defaultValue: number
|
|
): number {
|
|
const value = searchParams.get(key);
|
|
if (!value) return defaultValue;
|
|
const num = Number(value);
|
|
return Number.isFinite(num) ? num : defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Parse a URL parameter as one of a set of allowed values
|
|
*/
|
|
export function getUrlParamAsEnum<T extends string>(
|
|
searchParams: URLSearchParams,
|
|
key: string,
|
|
allowedValues: readonly T[],
|
|
defaultValue: T
|
|
): T {
|
|
const value = searchParams.get(key);
|
|
if (!value) return defaultValue;
|
|
return (allowedValues as readonly string[]).includes(value) ? (value as T) : defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Update URL without triggering navigation
|
|
*/
|
|
export function updateUrlParams(
|
|
params: Record<string, string | number | boolean | undefined | null>,
|
|
replace = true
|
|
): void {
|
|
const sp = new URLSearchParams(window.location.search);
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null || value === '' || value === false) {
|
|
sp.delete(key);
|
|
} else {
|
|
sp.set(key, String(value));
|
|
}
|
|
}
|
|
|
|
const qs = sp.toString();
|
|
const url = window.location.pathname + (qs ? `?${qs}` : '');
|
|
|
|
if (replace) {
|
|
window.history.replaceState(null, '', url);
|
|
} else {
|
|
window.history.pushState(null, '', url);
|
|
}
|
|
}
|
|
|