Files
plebsaber.stream/scripts/archive/store.ts
T
plebandClaude Opus 5.5 a759308711 Add archive exporter for the personal BeatLeader mirror
scripts/archive is a CLI that exports BeatLeader and ScoreSaber scores,
replays, score stats, leaderboard snapshots and BeatSaver map zips into an
append-only archive. Anchor the .gitignore 'archive' rule to the repo root
so it no longer hides scripts/archive. Record phase 1 results in the plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:30:02 -08:00

81 lines
2.7 KiB
TypeScript

// Archive filesystem helpers. The archive is append-only: files are written once
// (atomically) and never rewritten, except state.json.
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ARCHIVE_DIR, writeFileAtomic } from './config.ts';
export const archivePath = (...parts: string[]) => path.join(ARCHIVE_DIR, ...parts);
export const exists = (file: string) => fs.existsSync(file);
export function save(file: string, data: string | Uint8Array): void {
writeFileAtomic(file, data);
}
/** Today's date (UTC) as YYYY-MM-DD, used for dated snapshot filenames. */
export const today = () => new Date().toISOString().slice(0, 10);
export function listFiles(dir: string, ext?: string): string[] {
try {
return fs.readdirSync(dir).filter((f) => !f.includes('.tmp-') && (!ext || f.endsWith(ext)));
} catch {
return [];
}
}
export function readJson<T>(file: string): T {
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T;
}
/**
* Save a dated snapshot in `dir` only when the content differs from the newest existing one.
* Returns true if a new snapshot was written.
*/
export function saveSnapshotIfChanged(dir: string, body: Buffer): boolean {
const latest = listFiles(dir, '.json').sort().at(-1);
if (latest && fs.readFileSync(path.join(dir, latest)).equals(body)) return false;
save(path.join(dir, `${today()}.json`), body);
return true;
}
type State = Record<string, Record<string, unknown>>;
const STATE_FILE = () => archivePath('state.json');
export function readState(): State {
try {
return readJson<State>(STATE_FILE());
} catch {
return {};
}
}
export function updateState(section: string, patch: Record<string, unknown>): void {
const state = readState();
state[section] = { ...state[section], ...patch };
writeFileAtomic(STATE_FILE(), JSON.stringify(state, null, 2) + '\n');
}
/** Run `fn` over items with a progress line every `every` items. Errors are collected, not thrown. */
export async function eachWithProgress<T>(
label: string,
items: T[],
fn: (item: T) => Promise<'done' | 'skipped' | 'missing'>,
every = 50
): Promise<{ done: number; skipped: number; missing: number; failed: { item: T; error: string }[] }> {
const out = { done: 0, skipped: 0, missing: 0, failed: [] as { item: T; error: string }[] };
let i = 0;
for (const item of items) {
i++;
try {
out[await fn(item)]++;
} catch (err) {
out.failed.push({ item, error: (err as Error).message });
console.warn(` ${label}: ${(err as Error).message}`);
}
if (i % every === 0 || i === items.length) {
console.log(` ${label}: ${i}/${items.length} (new ${out.done}, existing ${out.skipped}, missing ${out.missing}, failed ${out.failed.length})`);
}
}
return out;
}