import type { Database } from "bun:sqlite"; import type { ReleaseFile } from "../bucket/repo.ts"; import { releaseRecords } from "../mirror/queries.ts"; /** A release as the public sees it: its notes are the operator's override when one is set (§24). */ export type Release = { version: string; save: string; publishedAt: number; notes: string; files: ReleaseFile[]; }; type ReleaseSetting = { version: string; hidden: number; notes: string | null }; /** * A repo's releases without the hidden ones, newest first by numeric dot-separated comparison (the * rule `koh update` uses, §12). Files are ordered by platform name, as Kepr 2 listed them. */ export function visibleReleases(kepr: Database, mirror: Database, repo: string): Release[] { const settings = new Map( kepr .query( "SELECT version, hidden, notes FROM release_settings WHERE repo = ?", ) .all(repo) .map((row) => [row.version, row]), ); return releaseRecords(mirror) .filter((record) => settings.get(record.version)?.hidden !== 1) .map((record) => ({ version: record.version, save: record.save, publishedAt: record.published_at, notes: settings.get(record.version)?.notes ?? record.notes, files: record.files.toSorted((a, b) => (a.platform < b.platform ? -1 : 1)), })) .sort((a, b) => compareVersions(b.version, a.version)); } export function compareVersions(a: string, b: string): number { const left = a.split(".").map(digits); const right = b.split(".").map(digits); for (let i = 0; i < Math.max(left.length, right.length); i++) { const [x, y] = [left[i] ?? "0", right[i] ?? "0"]; // Compared as digit strings, never as floats, so long components keep their precision. if (x.length !== y.length) return x.length - y.length; if (x !== y) return x < y ? -1 : 1; } // Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total. return a < b ? -1 : a > b ? 1 : 0; } // A component's leading digits without leading zeros ("0" when it has none), as parseInt reads it. function digits(part: string): string { return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, ""); } /** §22.2's content types for a release file. */ export function releaseContentType(name: string): string { const lower = name.toLowerCase(); if (lower.endsWith(".apk")) return "application/vnd.android.package-archive"; if (lower.endsWith(".zip")) return "application/zip"; return "application/octet-stream"; } export function countDownload( kepr: Database, repo: string, version: string, platform: string, ): void { kepr .query( `INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET count = count + 1`, ) .run(repo, version, platform); } /** A Nix tarball fetch (§22.2). */ export function countInstall(kepr: Database, repo: string): void { kepr .query( "INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1", ) .run(repo); } /** Download counts of a repo's files, keyed `/`. */ export function downloadCounts(kepr: Database, repo: string): Map { const rows = kepr .query<{ version: string; platform: string; count: number }, [string]>( "SELECT version, platform, count FROM downloads WHERE repo = ?", ) .all(repo); return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count])); }