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
↓ kepr-f412b53f.tar.gzatlas at this save
files changed5 files
package.json+1 −1src/admin/releases.ts+65 −0src/releases/releases.ts+53 −0src/server/app.ts+2 −0tests/console.test.ts+62 −0package.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": {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+}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+}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);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 <notes>.");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+});