repos.ts

7.8 KB · raw

1import type { Context, Hono } from "hono";2import { syncAll } from "../bucket/sync.ts";3import { operatorIssues } from "../issues/store.ts";4import { languageLines, repoStats } from "../mirror/queries.ts";5import { openMirror, quarantinedPaths } from "../mirror/store.ts";6import { html } from "../pages/html.ts";7import { formatDate, plural } from "../pages/layout.ts";8import { operatorReleases, visibleReleases } from "../releases/releases.ts";9import { repoText } from "../server/db.ts";10import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";11import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";1213const MAX_FIELD = 500;14const HTTP_URL = /^https?:\/\/[^\s]+$/i;15const SORT_ORDER = /^-?\d{1,6}$/;16const MAX_LANGUAGES = 5;17const MAX_LANGUAGE = 30;1819/** Shown languages as typed, comma-separated; blanks between commas are dropped. */20function languageNames(value: string): string[] {21	return value22		.split(",")23		.map((name) => name.trim())24		.filter((name) => name !== "");25}2627/** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */28export function registerRepos(app: Hono, ctx: AdminContext): void {29	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));3031	app.post("/admin/sync", async (c) => {32		await syncAll(ctx.sync);33		return c.redirect("/admin/repos", 303);34	});3536	app.post("/admin/repos/:repo", async (c) => {37		const repo = findRepo(ctx.kepr, c.req.param("repo"));38		if (!repo) return c.notFound();39		const fields = await textFields(c);40		const text = (name: string) => (fields[name] ?? "").trim();41		const languages = languageNames(fields.languages ?? "");42		const edit: RepoEdit = {43			public: fields.public === "1" ? 1 : 0,44			category: text("category"),45			homepage: text("homepage"),46			sort_order: Number(text("sort_order") || "0"),47			install1: text("install1"),48			install2: text("install2"),49			install3: text("install3"),50			// Joined with the separator `publicRepo` splits on, so a name never contains it.51			languages: languages.join(", "),52		};53		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);54		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {55			return refuse(`keep each field under ${MAX_FIELD} characters.`);56		}57		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {58			return refuse("the homepage must be an http or https address.");59		}60		if (!SORT_ORDER.test(text("sort_order") || "0")) return refuse("the order must be a number.");61		if ([edit.install1, edit.install2, edit.install3].some((line) => /[\r\n]/.test(line))) {62			return refuse("an install line is one line.");63		}64		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");65		if (languages.some((name) => name.length > MAX_LANGUAGE)) {66			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);67		}68		if (languages.some((name) => /\p{C}/u.test(name))) {69			return refuse("a language name is printable text.");70		}71		updateRepo(ctx.kepr, repo.name, edit);72		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);73	});74}7576function reposPage(c: Context, ctx: AdminContext, error: string): string {77	const repos = allRepos(ctx.kepr);78	const selected = repos.find((repo) => repo.name === c.req.query("repo")) ?? repos[0];79	const body = html`${notice(error)}80<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>81<form method="post" action="/admin/sync">82${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>83${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}84<div class="admin-repos">85<nav aria-label="repositories">86${repos.map(87	(repo) => html`<a class="issue-row" id="repo-${repo.name}"88href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>89<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>90<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>91${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,92)}93</nav>94${selected && repoForm(c, ctx, selected)}95</div>`;96	return adminPage(c, ctx, "repos", body);97}9899/** The selected repo at a glance: its numbers, languages, description and releases (§24). */100function workspace(ctx: AdminContext, repo: AdminRepo) {101	const mirror = openMirror(ctx.mirrorDir, repo.name);102	const stats = repoStats(mirror);103	const releases = operatorReleases(ctx.kepr, mirror, repo.name);104	const downloads = (release: (typeof releases)[number]) =>105		release.downloads.reduce((sum, [, count]) => sum + count, 0);106	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);107	const vital = (label: string, value: string | number) =>108		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;109	const own = repoText(ctx.kepr, repo.name).description !== "";110	return html`<div class="admin-vitals">111${vital("saves", stats.saves)}112${vital("lines", stats.lines)}113${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}114${vital("open issues", open.length)}115${vital(116	"downloads",117	releases.reduce((sum, release) => sum + downloads(release), 0),118)}119</div>120<p class="issue-meta mono">detected languages:121${languageLines(mirror).map(122	([language, lines], i) =>123		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,124)}</p>125<p class="issue-meta mono">description: ${own ? "your own words" : "from the README"} ·126<a href="/${repo.name}">edit on its page ↗</a></p>127${128	releases.length > 0 &&129	html`<ul class="admin-releases mono">${releases.map(130		(release) => html`<li><a href="/admin/releases#${repo.name}-${release.version}">131${release.version}</a> <span class="muted">${release.hidden ? "hidden" : "shown"} · ${plural(132			downloads(release),133			"download",134		)}</span></li>`,135	)}</ul>`136}`;137}138139function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {140	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));141	const input = (name: keyof RepoEdit, label: string) =>142		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>143<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;144	const synced =145		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;146	return html`<article class="issue-post" id="repo-editor">147<p class="issue-post-head mono">148<span class="issue-author">149${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>150<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>151${workspace(ctx, repo)}152${153	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`154}155${156	quarantined.length > 0 &&157	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`158}159<form class="issues-form" method="post" action="/admin/repos/${repo.name}">160${formField(c)}161<label class="issues-label">162<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>163${input("category", "category")}164${input("homepage", "homepage")}165${input("sort_order", "card order")}166<p class="issue-meta mono">Lower numbers appear first. Repositories with the same number sort by name.</p>167${input("install1", "install line 1")}168${input("install2", "install line 2")}169${input("install3", "install line 3")}170${input("languages", "shown languages")}171<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>172<button class="issues-submit" type="submit">save ${repo.name}</button>173</form>174</article>`;175}