import type { Context, Hono } from "hono"; import { syncAll } from "../bucket/sync.ts"; import { operatorIssues } from "../issues/store.ts"; import { languageLines, repoStats } from "../mirror/queries.ts"; import { openMirror, quarantinedPaths } from "../mirror/store.ts"; import { html } from "../pages/html.ts"; import { formatDate, plural } from "../pages/layout.ts"; import { operatorReleases, visibleReleases } from "../releases/releases.ts"; import { repoText } from "../server/db.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts"; const MAX_FIELD = 500; const HTTP_URL = /^https?:\/\/[^\s]+$/i; const MAX_LANGUAGES = 5; const MAX_LANGUAGE = 30; /** Shown languages as typed, comma-separated; blanks between commas are dropped. */ function languageNames(value: string): string[] { return value .split(",") .map((name) => name.trim()) .filter((name) => name !== ""); } /** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */ export function registerRepos(app: Hono, ctx: AdminContext): void { app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, ""))); app.post("/admin/sync", async (c) => { await syncAll(ctx.sync); return c.redirect("/admin/repos", 303); }); app.post("/admin/repos/:repo", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const fields = await textFields(c); const text = (name: string) => (fields[name] ?? "").trim(); const languages = languageNames(fields.languages ?? ""); const edit: RepoEdit = { public: fields.public === "1" ? 1 : 0, category: text("category"), homepage: text("homepage"), // Joined with the separator `publicRepo` splits on, so a name never contains it. languages: languages.join(", "), issues_paused: fields.issues_paused === "1" ? 1 : 0, }; const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400); if (Object.values(fields).some((value) => value.length > MAX_FIELD)) { return refuse(`keep each field under ${MAX_FIELD} characters.`); } if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) { return refuse("the homepage must be an http or https address."); } if (languages.length > MAX_LANGUAGES) return refuse("up to five languages."); if (languages.some((name) => name.length > MAX_LANGUAGE)) { return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`); } if (languages.some((name) => /\p{C}/u.test(name))) { return refuse("a language name is printable text."); } updateRepo(ctx.kepr, repo.name, edit); return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303); }); } function reposPage(c: Context, ctx: AdminContext, error: string): string { const repos = allRepos(ctx.kepr); const selected = repos.find((repo) => repo.name === c.req.query("repo")) ?? repos[0]; const body = html`${notice(error)}

Choose a repository to manage its visibility and public page.

${formField(c)}
${repos.length === 0 && html`

no repos found in the bucket yet

`}
${selected && repoForm(c, ctx, selected)}
`; return adminPage(c, ctx, "repos", body); } /** The selected repo at a glance: its numbers, languages, description and releases (§24). */ function workspace(ctx: AdminContext, repo: AdminRepo) { const mirror = openMirror(ctx.mirrorDir, repo.name); const stats = repoStats(mirror); const releases = operatorReleases(ctx.kepr, mirror, repo.name); const downloads = (release: (typeof releases)[number]) => release.downloads.reduce((sum, [, count]) => sum + count, 0); const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name); const vital = (label: string, value: string | number) => html`
${String(value)}${label}
`; const own = repoText(ctx.kepr, repo.name).description !== ""; return html`
${vital("saves", stats.saves)} ${vital("lines", stats.lines)} ${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")} ${vital("open issues", open.length)} ${vital( "downloads", releases.reduce((sum, release) => sum + downloads(release), 0), )}

detected languages: ${languageLines(mirror).map( ([language, lines], i) => html`${i > 0 ? " · " : " "}${language} ${plural(lines, "line")}`, )}

description: ${own ? "your own words" : "from the README"} · edit on its page ↗

${ releases.length > 0 && html`` }`; } function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) { const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name)); const input = (name: keyof RepoEdit, label: string) => html` `; const synced = repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`; return html`

${repo.public ? html`${repo.name}` : repo.name} ${repo.public ? "public" : "private"}${synced}

${workspace(ctx, repo)} ${ repo.last_sync_error && html`

sync error: ${repo.last_sync_error}

` } ${ quarantined.length > 0 && html`

${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}

` }
${formField(c)} ${input("category", "category")} ${input("homepage", "homepage")} ${input("languages", "shown languages")}

Up to five, separated by commas. Leave blank to show the detected one.

`; }