1import type { Database } from "bun:sqlite";2import type { Bucket } from "../bucket/reader.ts";3import { type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";4import { releaseRecords } from "../mirror/queries.ts";5import { forgetRelease } from "../mirror/store.ts";678export type Release = {9 version: string;10 save: string;11 publishedAt: number;12 notes: string;13 files: ReleaseFile[];14};1516type ReleaseSetting = { version: string; hidden: number; notes: string | null };171819202122export function visibleReleases(kepr: Database, mirror: Database, repo: string): Release[] {23 const settings = new Map(24 kepr25 .query<ReleaseSetting, [string]>(26 "SELECT version, hidden, notes FROM release_settings WHERE repo = ?",27 )28 .all(repo)29 .map((row) => [row.version, row]),30 );31 return releaseRecords(mirror)32 .filter((record) => settings.get(record.version)?.hidden !== 1)33 .map((record) => ({34 version: record.version,35 save: record.save,36 publishedAt: record.published_at,37 notes: settings.get(record.version)?.notes ?? record.notes,38 files: record.files.toSorted((a, b) => (a.platform < b.platform ? -1 : 1)),39 }))40 .sort((a, b) => compareVersions(b.version, a.version));41}4243export function compareVersions(a: string, b: string): number {44 const left = a.split(".").map(digits);45 const right = b.split(".").map(digits);46 for (let i = 0; i < Math.max(left.length, right.length); i++) {47 const [x, y] = [left[i] ?? "0", right[i] ?? "0"];48 49 if (x.length !== y.length) return x.length - y.length;50 if (x !== y) return x < y ? -1 : 1;51 }52 53 return a < b ? -1 : a > b ? 1 : 0;54}555657function digits(part: string): string {58 return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");59}606162export function releaseContentType(name: string): string {63 const lower = name.toLowerCase();64 if (lower.endsWith(".apk")) return "application/vnd.android.package-archive";65 if (lower.endsWith(".zip")) return "application/zip";66 return "application/octet-stream";67}6869export function countDownload(70 kepr: Database,71 repo: string,72 version: string,73 platform: string,74): void {75 kepr76 .query(77 `INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)78 ON CONFLICT DO UPDATE SET count = count + 1`,79 )80 .run(repo, version, platform);81}828384export function countInstall(kepr: Database, repo: string): void {85 kepr86 .query(87 "INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",88 )89 .run(repo);90}919293export function downloadCounts(kepr: Database, repo: string): Map<string, number> {94 const rows = kepr95 .query<{ version: string; platform: string; count: number }, [string]>(96 "SELECT version, platform, count FROM downloads WHERE repo = ?",97 )98 .all(repo);99 return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));100}101102103export function publicDownloadCount(kepr: Database): number {104 return (105 kepr106 .query<{ n: number }, []>(107 `SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d108 JOIN repos r ON r.name = d.repo AND r.public = 1109 LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version110 WHERE COALESCE(s.hidden, 0) = 0`,111 )112 .get()?.n ?? 0113 );114}115116117export type OperatorRelease = {118 version: string;119 recordNotes: string;120 notes: string;121 hidden: boolean;122 downloads: [platform: string, count: number][];123};124125export function operatorReleases(126 kepr: Database,127 mirror: Database,128 repo: string,129): OperatorRelease[] {130 const counts = downloadCounts(kepr, repo);131 const settings = kepr132 .query<ReleaseSetting, [string]>(133 "SELECT version, hidden, notes FROM release_settings WHERE repo = ?",134 )135 .all(repo);136 return releaseRecords(mirror)137 .map((record) => {138 const setting = settings.find((row) => row.version === record.version);139 return {140 version: record.version,141 recordNotes: record.notes,142 notes: setting?.notes ?? record.notes,143 hidden: setting?.hidden === 1,144 downloads: record.files.map((file): [string, number] => [145 file.platform,146 counts.get(`${record.version}/${file.platform}`) ?? 0,147 ]),148 };149 })150 .sort((a, b) => compareVersions(b.version, a.version));151}152153154export function setRelease(155 kepr: Database,156 repo: string,157 version: string,158 hidden: boolean,159 notes: string | null,160): void {161 kepr162 .query(163 `INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)164 ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,165 )166 .run(repo, version, hidden ? 1 : 0, notes);167}168169170171172173174175export async function deleteRelease(176 bucket: Bucket,177 kepr: Database,178 mirror: Database,179 repo: string,180 version: string,181): Promise<void> {182 const prefix = `${repo}/releases/${version}/`;183 await bucket.remove(`${prefix}release.json`);184 for (const key of await bucket.list(prefix)) await bucket.remove(key);185 if ((await readReleasesLatest(bucket, repo)) === version) {186 const newest = releaseRecords(mirror)187 .map((record) => record.version)188 .filter((other) => other !== version)189 .sort((a, b) => compareVersions(b, a))[0];190 const latest = `${repo}/releases/latest`;191 if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");192 else await bucket.remove(latest);193 }194 forgetRelease(mirror, version);195 kepr.transaction(() => {196 for (const table of ["release_settings", "downloads"]) {197 198 kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);199 }200 })();201}