f412b53f8e44

add the admin releases section: every version per repo with download counts per platform, displayed notes stored in kepr.db over the record's, and hide or unhide, tested to drop a hidden version from pages, latest and every feed (v3.0.37)

time
2026-09-26 20:22 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
1a15eaa5791b
5 files package.json+1 −1src/admin/releases.ts+65 −0src/releases/releases.ts+53 −0src/server/app.ts+2 −0tests/console.test.ts+62 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.36",3+	"version": "3.0.37",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/releases.ts+65 −0
@@ -0,0 +1,65 @@1+import type { Context, Hono } from "hono";2+import { MAX_CHARS } from "../issues/spam.ts";3+import { openMirror } from "../mirror/store.ts";4+import { html } from "../pages/html.ts";5+import { plural } from "../pages/layout.ts";6+import { type OperatorRelease, operatorReleases, setRelease } from "../releases/releases.ts";7+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";8+import { allRepos, findRepo } from "./store.ts";9+10+/**11+ * §24's Releases section: versions with download counts, displayed notes and hiding. Everything12+ * lives in kepr.db; nothing is deleted, since the bucket key is read-only.13+ */14+export function registerReleases(app: Hono, ctx: AdminContext): void {15+	app.get("/admin/releases", (c) => c.html(releasesPage(c, ctx, "")));16+17+	app.post("/admin/releases/:repo/:version", async (c) => {18+		const repo = findRepo(ctx.kepr, c.req.param("repo"));19+		const release =20+			repo &&21+			operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name).find(22+				(r) => r.version === c.req.param("version"),23+			);24+		if (!repo || !release) return c.notFound();25+		const fields = await textFields(c);26+		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");27+		if (notes.length > MAX_CHARS) {28+			const error = `${repo.name} ${release.version}: notes are over ${MAX_CHARS.toLocaleString("en-US")} characters.`;29+			return c.html(releasesPage(c, ctx, error), 400);30+		}31+		// Notes equal to the record's are no override, so a later record edit would show through.32+		const override = notes === release.recordNotes ? null : notes;33+		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);34+		return c.redirect(`/admin/releases#${repo.name}-${release.version}`, 303);35+	});36+}37+38+function releasesPage(c: Context, ctx: AdminContext, error: string): string {39+	const sections = allRepos(ctx.kepr).flatMap((repo) => {40+		const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);41+		if (releases.length === 0) return [];42+		return html`<section class="repo-section">43+<h2 class="section-label mono">${repo.name}</h2>44+${releases.map((release) => releaseForm(c, repo.name, release))}45+</section>`;46+	});47+	const body = html`${notice(error)}48+${sections.length > 0 ? sections : html`<p class="muted mono save-empty">no releases in the bucket yet</p>`}`;49+	return adminPage(c, ctx, "releases", body);50+}51+52+function releaseForm(c: Context, repo: string, release: OperatorRelease) {53+	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);54+	const id = `${repo}-${release.version}`;55+	return html`<article class="issue-post" id="${id}">56+<p class="issue-post-head mono"><span class="issue-author">${release.version}</span><span>${release.hidden ? "hidden" : "shown"}</span><span>${plural(total, "download")}</span>${release.downloads.map(([platform, n]) => html`<span class="muted">${platform} ${n}</span>`)}</p>57+<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">58+${formField(c)}59+<label class="issues-label" for="${id}-notes">notes${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>60+<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3" maxlength="${MAX_CHARS}">${release.notes}</textarea>61+<label class="issues-label"><input type="checkbox" name="hidden" value="1"${release.hidden ? html` checked` : ""}> hidden</label>62+<button class="issues-submit" type="submit">save ${release.version}</button>63+</form>64+</article>`;65+}

view file

src/releases/releases.ts+53 −0
@@ -110,3 +110,56 @@110110 			.get()?.n ?? 0111111 	);112112 }113+114+/** A release as the operator sees it (§24): hidden ones too, and the record's own notes. */115+export type OperatorRelease = {116+	version: string;117+	recordNotes: string;118+	notes: string;119+	hidden: boolean;120+	downloads: [platform: string, count: number][];121+};122+123+export function operatorReleases(124+	kepr: Database,125+	mirror: Database,126+	repo: string,127+): OperatorRelease[] {128+	const counts = downloadCounts(kepr, repo);129+	const settings = kepr130+		.query<ReleaseSetting, [string]>(131+			"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",132+		)133+		.all(repo);134+	return releaseRecords(mirror)135+		.map((record) => {136+			const setting = settings.find((row) => row.version === record.version);137+			return {138+				version: record.version,139+				recordNotes: record.notes,140+				notes: setting?.notes ?? record.notes,141+				hidden: setting?.hidden === 1,142+				downloads: record.files.map((file): [string, number] => [143+					file.platform,144+					counts.get(`${record.version}/${file.platform}`) ?? 0,145+				]),146+			};147+		})148+		.sort((a, b) => compareVersions(b.version, a.version));149+}150+151+/** Hides or shows a version and sets its displayed notes; null notes fall back to the record's. */152+export function setRelease(153+	kepr: Database,154+	repo: string,155+	version: string,156+	hidden: boolean,157+	notes: string | null,158+): void {159+	kepr160+		.query(161+			`INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)162+			ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,163+		)164+		.run(repo, version, hidden ? 1 : 0, notes);165+}

view file

src/server/app.ts+2 −0
@@ -5,6 +5,7 @@55 import { secureHeaders } from "hono/secure-headers";66 import { registerStatus } from "../admin/console.ts";77 import { registerIssues } from "../admin/issues.ts";8+import { registerReleases } from "../admin/releases.ts";89 import { registerRepos } from "../admin/repos.ts";910 import { openBucket } from "../bucket/reader.ts";1011 import { isSafePath } from "../bucket/repo.ts";@@ -76,6 +77,7 @@7677 	registerStatus(app, admin);7778 	registerRepos(app, admin);7879 	registerIssues(app, admin);80+	registerReleases(app, admin);7981 	registerHousekeeping(app, ctx);8082 	registerFrontPages(app, ctx);8183 	registerDocsPages(app, ctx);

view file

tests/console.test.ts+62 −0
@@ -241,3 +241,65 @@241241 		filedAt: 1,242242 	});243243 }244+245+describe("releases: counts, displayed notes and hiding", () => {246+	let inst: Instance;247+	let operator: Browser;248+	const text = async (path: string) => (await inst.app.request(path)).text();249+	const edit = (version: string, fields: Record<string, string>) =>250+		operator.post(`/admin/releases/app/${version}`, fields);251+252+	beforeAll(async () => {253+		const repos = await makeRepos(["app"]);254+		const save = repos.saves.app as string;255+		for (const version of ["1.0.0", "1.1.0"]) {256+			await writeRelease(repos.bucketDir, "app", {257+				version,258+				save,259+				notes: `notes for ${version}`,260+				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],261+			});262+		}263+		inst = await instance(repos.bucketDir);264+		operator = new Browser(inst.app);265+		await operator.signIn();266+		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });267+		await inst.app.request("/app/releases/1.1.0/linux-x86_64");268+	});269+270+	test("every version is listed with its download counts and notes", async () => {271+		const page = await (await operator.get("/admin/releases")).text();272+		expect(page).toContain('id="app-1.1.0"');273+		expect(page).toContain('id="app-1.0.0"');274+		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));275+		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');276+		expect(page).toContain(">notes for 1.1.0</textarea>");277+	});278+279+	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {280+		expect((await edit("1.1.0", { notes: "Better <notes>." })).status).toBe(303);281+		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");282+		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");283+		expect(await (await operator.get("/admin/releases")).text()).toContain("(edited)");284+		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);285+		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();286+		expect(row).toEqual({ notes: null });287+		expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);288+	});289+290+	test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {291+		expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);292+		expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");293+		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");294+		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");295+		expect(await text("/app/releases")).not.toContain("1.1.0");296+		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);297+		const page = await (await operator.get("/admin/releases")).text();298+		expect(page).toContain('id="app-1.1.0"');299+		expect(page).toContain('value="1" checked> hidden');300+		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);301+		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");302+		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);303+		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);304+	});305+});

view file