From 4ceca6c30ef19c14cd4ba76a3cbd9e894f91fdc6 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 13 Jul 2026 23:31:34 -0700 Subject: [PATCH] chore: random things --- framework/sitegen-wrap.marko | 5 +- lib/progress.ts | 2 +- src/file-viewer/indexer/service.ts | 12 +- src/file-viewer/indexer/studio.ts | 148 ++++++++++++++++++ src/file-viewer/scripts/canvas_2018.client.ts | 2 +- src/friend-auth.ts | 71 ++++++--- src/pages/admin/index.mdo | 2 +- src/pages/index.marko | 4 +- src/pages/waterfalls.marko | 8 +- src/q+a/image.ts | 4 +- src/q+a/views/image-embed.marko | 2 + src/source-of-truth.ts | 11 ++ src/tags/clover-video.css | 6 + 13 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 src/file-viewer/indexer/studio.ts diff --git a/framework/sitegen-wrap.marko b/framework/sitegen-wrap.marko index e163bdb59413bbd04c652340b2a062f21acedea4..7a0b5af9db6b05c0a0d47cabef8b563d400b8ba0 100644 --- a/framework/sitegen-wrap.marko +++ b/framework/sitegen-wrap.marko @@ -1,2 +1,5 @@ - + { + $global.cloverScripts = input.cloverScripts; + $global.cloverViewProps = input.attrs; +})()> <${input.page} ...input.attrs/> diff --git a/lib/progress.ts b/lib/progress.ts index 40ef7c0b801c5d5f6081066571b512d82d389af4..00af0fc2d9d918d126e1d73eddf49c9d15910e92 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -1857,8 +1857,8 @@ export const internals: { import { ASSERT, UNWRAP } from "./assert.ts"; import * as async from "./async.ts"; import * as bytes from "./bytes.ts"; -import * as exceptions from "./exception.ts"; import { Events } from "./Events.ts"; +import * as exceptions from "./exception.ts"; import * as log from "./log.ts"; import * as stack from "./log/stack.ts"; import * as stream from "./stream.ts"; diff --git a/src/file-viewer/indexer/service.ts b/src/file-viewer/indexer/service.ts index 2244bf8e1c590cad4ac9c2e64a0b9231f9f785a4..1c9d237eea330d3248e50c67ce882bfbdd9c44b6 100644 --- a/src/file-viewer/indexer/service.ts +++ b/src/file-viewer/indexer/service.ts @@ -61,6 +61,7 @@ export class Service { sweepIntervalMs: number; watchEnabled: boolean; watcher: fs.FSWatcher | null = null; + lastSweep: { startedAt: number; endedAt: number | null; error: string | null } | null = null; // paths that changed according to the watcher, waiting to be scanned #dirty = new Map(); @@ -100,7 +101,10 @@ export class Service { return; } try { - if (!subPath) this.#sweeping = true; + if (!subPath) { + this.#sweeping = true; + this.lastSweep = { startedAt: Date.now() / 1000, endedAt: null, error: null }; + } const target = subPath ? this.scanner.root.join("." + path.posix.normalize("/" + subPath)) : this.scanner.root; @@ -108,9 +112,13 @@ export class Service { this.#runDirMeta(); if (!subPath) await this.#maintenance(); } catch (err) { + if (!subPath && this.lastSweep) this.lastSweep.error = String(err); console.error(`${reason} scan failed:`, err); } finally { - if (!subPath) this.#sweeping = false; + if (!subPath) { + this.#sweeping = false; + if (this.lastSweep) this.lastSweep.endedAt = Date.now() / 1000; + } } } diff --git a/src/file-viewer/indexer/studio.ts b/src/file-viewer/indexer/studio.ts new file mode 100644 index 0000000000000000000000000000000000000000..4562912bcde9c0c4b411dfba9ff05217e301d68f --- /dev/null +++ b/src/file-viewer/indexer/studio.ts @@ -0,0 +1,148 @@ +import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; +import * as mime from "@clo/lib/mime"; +import * as progress from "@clo/lib/progress"; +import * as registry from "./registry.ts"; +import type { Service } from "./service.ts"; + +type FileRow = { + id: number; + path: string; + kind: number; + size: number; + timestamp_updated: number | string; + duration: number; + dimensions: string; +}; +type StateRow = { file: number; name: string; version: number; status: number; updated: number; error: string | null }; + +const seconds = (value: number | string) => (typeof value === "string" ? Date.parse(value) : value) / 1000; +const extension = (path: string) => { + const name = path.split("/").at(-1) ?? ""; + const dot = name.lastIndexOf("."); + return (dot > 0 ? name.slice(dot) : name).toLowerCase(); +}; + +export function stats(service: Service | null) { + const db = MediaFile.db.node; + const all = db.prepare("select id,path,kind,size,timestamp_updated,duration,dimensions from media_files") + .all() as FileRow[]; + const files = all.filter((file) => file.kind === 1); + const extensions = new Map(files.map((file) => [file.id, extension(file.path)])); + const recorded = db.prepare(` + select fp.file,p.name,fp.version,fp.status,fp.updated,fp.error + from file_processors fp join processors p on p.id=fp.processor + `).all() as StateRow[]; + const byFile = new Map>(); + for (const row of recorded) { + const states = byFile.get(row.file) ?? new Map(); + states.set(row.name, row); + byFile.set(row.file, states); + } + const folders = new Map(); + const types = new Map(); + for (const file of files) { + const top = file.path.split("/")[1] ?? ""; + const folder = folders.get(top) ?? { name: top, files: 0, bytes: 0 }; + folder.files++; + folder.bytes += file.size; + folders.set(top, folder); + const ext = extensions.get(file.id) ?? ""; + const type = types.get(ext) ?? { ext, files: 0, bytes: 0 }; + type.files++; + type.bytes += file.size; + types.set(ext, type); + } + const processors = registry.processors.map((p) => { + let applicable = 0; + let done = 0; + let failed = 0; + let stale = 0; + for (const file of files) { + const ext = extensions.get(file.id) ?? ""; + if (p.include ? !p.include.has(ext) : p.exclude?.has(ext)) continue; + applicable++; + const state = byFile.get(file.id)?.get(p.name); + if (state?.version !== p.version) { + if (state) stale++; + } else if (state.status === 1) done++; + else if (state.status === 2) failed++; + } + return { + name: p.name, + title: p.title, + version: p.version, + runnable: registry.canExecute(p), + applicable, + done, + failed, + stale, + outputs: null, + bytes: null, + seconds: null, + }; + }); + const failures = files.flatMap((file) => { + const states = [...(byFile.get(file.id)?.values() ?? [])]; + const errors = states.flatMap((state) => + state.status === 2 && state.error !== null + ? [{ processor: state.name, error: state.error, at: state.updated / 1000 }] + : [] + ); + return errors.length + ? [{ + path: file.path, + size: file.size, + mime: mime.for(file.path), + made: states.filter((state) => state.status === 1).map((state) => state.name), + errors, + }] + : []; + }); + const recent = [...files].sort((a, b) => seconds(b.timestamp_updated) - seconds(a.timestamp_updated)) + .slice(0, 12).map((file) => ({ path: file.path, size: file.size, at: seconds(file.timestamp_updated) })); + const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 12) + .map((file) => ({ path: file.path, size: file.size, duration: file.duration, dimensions: file.dimensions })); + const derived = db.prepare("select count(*) as files,coalesce(sum(size),0) as bytes from derived_files").get() as { + files: number; + bytes: number; + }; + return { + indexer: { enabled: service !== null, watching: Boolean(service?.watcher), nextSweepAt: null }, + lastSweep: service?.lastSweep ?? null, + files: files.length, + directories: all.length - files.length, + bytes: files.reduce((sum, file) => sum + file.size, 0), + folders: [...folders.values()].sort((a, b) => b.bytes - a.bytes), + types: [...types.values()].sort((a, b) => b.bytes - a.bytes), + processors, + derived, + failures, + recent, + largest, + }; +} + +const firstSeen = new WeakMap(); + +export function activity(root: progress.Root) { + const convert = (node: progress.ReadOnlyNode): object => { + if (!firstSeen.has(node)) firstSeen.set(node, Date.now() / 1000); + const match = /^(\/.*?) - (.*)$/.exec(node.text); + const children = (node.sortChildren ? [...node.children].sort(node.sortChildren) : node.children) + .filter((child) => !child.hidden && (!child.passive || child.children.length)).map(convert); + return { + path: match?.[1] ?? null, + text: match?.[2] ?? node.text, + count: node.valueFormatter(node.value, node.total), + progress: node.showTotal && node.total && node.total > 0 ? node.value / node.total : null, + eta: node.estimatedTime ? node.estimatedTime / 1000 : null, + since: firstSeen.get(node), + logs: node.logs.slice(-3).map((entry) => ({ + level: entry.level ?? "info", + text: entry.text.replace(/\x1b\[[\d;]*m/g, ""), + })), + children, + }; + }; + return root.active.filter((node) => !node.hidden && (!node.passive || node.children.length)).map(convert); +} diff --git a/src/file-viewer/scripts/canvas_2018.client.ts b/src/file-viewer/scripts/canvas_2018.client.ts index a1412470e3aa5244ca174f211653e47bf96d1fd4..b13170609cc64f7b06397b1d50e76a13026529fb 100644 --- a/src/file-viewer/scripts/canvas_2018.client.ts +++ b/src/file-viewer/scripts/canvas_2018.client.ts @@ -1,6 +1,6 @@ // This canvas is based on the maze generation algo in Tanks. This was // originally written in C++ as a single function in 2018, and was ported to TS -// by Chloe in 2025 for the cotyledon canvas. +// by Clover in 2025 for the cotyledon canvas. // // The main difference is that this version is a visualization, rather than the // practical function. Instead of taking a millisecond, only 5 steps are diff --git a/src/friend-auth.ts b/src/friend-auth.ts index 727a6c077d55d69b4d09170a9316347c0af05e1d..dc6a4a3c31e52b035a0c4d71564cdf53f278c652 100644 --- a/src/friend-auth.ts +++ b/src/friend-auth.ts @@ -1,7 +1,15 @@ -let hardcoded = { - friendPassword: "", - monthlyPasswords: [] as Array<{ password: string; start: string }>, - getForFile: (_: string) => [] as string[], +interface Grant { + password: string; + start: string; + end: string | null; + group?: string; +} + +let hardcoded: { + monthlyPasswords: readonly Grant[]; + getForFile?: (file: string) => string[]; +} = { + monthlyPasswords: [], }; try { hardcoded = require("./friends/hardcoded-password.ts"); @@ -14,7 +22,7 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month export const getForFile = hardcoded.getForFile ?? (() => []); -function getFriendsCookiePassword(c: Context, passwords: string[]) { +function getFriendsCookiePassword(c: Context, passwords: readonly string[]) { const cookie = c.req.header("Cookie"); if (!cookie) return null; const cookies = cookie.split("; ").map((x) => x.split("=")); @@ -29,9 +37,9 @@ function getFriendsCookiePassword(c: Context, passwords: string[]) { export function requireFriendAuth( c: Context, - passwords = [hardcoded.friendPassword], + passwords: readonly string[] = [], ) { - if (passwords.length === 0 || passwords[0]!.length === 0) { + if (passwords.length === 0) { return serveAsset(c, "/friends/misconfigure", 501); } const k = c.req.query("password") || c.req.query("k"); @@ -59,8 +67,29 @@ let incorrectMap: Record = {}; app.use(friendAuthMiddleware); app.get("/friends", (c) => { + const grant = getGrant(c, getForRoute(c.req.path)); return view.serve(c, "friends/index", { - minimumDate: getMinimumDate(c), + minimumDate: grant?.start ?? null, + maximumDate: grant?.end ?? null, + group: grant?.group ?? "default", + }); +}); + +app.post("/friends/logout", (c) => { + return c.body(null, 303, { + Location: "/friends", + "Set-Cookie": "friends_password=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + }); +}); + +app.get("/friends/:year/:entry", (c) => { + const entry = getRouteEntry(c.req.path); + if (!entry) return c.notFound(); + const grant = getGrant(c, getForRoute(c.req.path)); + return view.serve(c, `friends/entries/${entry.year}/${entry.name}` as view.Key, { + minimumDate: grant?.start ?? null, + maximumDate: grant?.end ?? null, + group: grant?.group ?? "default", }); }); @@ -94,7 +123,7 @@ async function friendAuthMiddleware(c: Context, next: Next) { } function getForRoute(route: string) { - const passwords = [hardcoded.friendPassword]; + const passwords: string[] = []; const month = getRouteMonth(route); if (route === "/friends") { passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password)); @@ -102,22 +131,28 @@ function getForRoute(route: string) { } if (!month) return passwords; for (const grant of hardcoded.monthlyPasswords) { - if (month >= grant.start) passwords.push(grant.password); + if (month >= grant.start && (grant.end == null || month <= grant.end)) { + passwords.push(grant.password); + } } return passwords; } -function getMinimumDate(c: Context) { - const password = getFriendsCookiePassword(c, getForRoute(c.req.path)); - if (!password || password === hardcoded.friendPassword) return null; - const grant = hardcoded.monthlyPasswords.find((grant) => grant.password === password); - return grant?.start ?? null; +function getGrant(c: Context, passwords: readonly string[]) { + const password = getFriendsCookiePassword(c, passwords); + if (!password || !passwords.includes(password)) return null; + return hardcoded.monthlyPasswords.find((grant) => grant.password === password) ?? null; } function getRouteMonth(route: string) { - const [, year, month] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d)-/) ?? []; - if (!year || !month) return null; - return `20${year}-${month}`; + const entry = getRouteEntry(route); + if (!entry) return null; + return `20${entry.year}-${entry.name.slice(0, 2)}`; +} + +function getRouteEntry(route: string) { + const [, year, name] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d-[a-z]{3})$/) ?? []; + return year && name ? { year, name } : null; } function isFriendAuthPage(route: string) { diff --git a/src/pages/admin/index.mdo b/src/pages/admin/index.mdo index 85b5d2da02e92248703192f4ec343cecfb2b3b14..bf149cca099a394ed034c338409e4697b077f589 100644 --- a/src/pages/admin/index.mdo +++ b/src/pages/admin/index.mdo @@ -4,7 +4,7 @@ meta: ---
-# hi chloe +# hi snow remember that you're loved. diff --git a/src/pages/index.marko b/src/pages/index.marko index c3c4e5b141a94f6c5291633f8a4f3e9b7acc9fdd..c60b9500c64f6fc96118f5d3bfe4132ff12f4967 100644 --- a/src/pages/index.marko +++ b/src/pages/index.marko @@ -44,9 +44,9 @@ export const meta = {

things

diff --git a/src/pages/waterfalls.marko b/src/pages/waterfalls.marko index a1279dcc5be92e5f09ed2a05f77f4466d368adde..e7f833c9a92a616dcf0a6ee13ad7de763095c357 100644 --- a/src/pages/waterfalls.marko +++ b/src/pages/waterfalls.marko @@ -11,12 +11,8 @@ export const theme = {
-

waterfalls and gender identity update

-

- this is a song about identity, jealousness, and the seasons. more - specifically, it's a personal account of how it felt realizing i was a - transgender woman. enjoy. -

+

waterfalls

+

this is a song about identity, jealousness, and the seasons. enjoy.

{ document.body.style.background = "#1a1b1c"; - return document.querySelector("main")!.getBoundingClientRect().height; + return document.querySelector("main")!.offsetHeight; }); return await p.screenshot({ type: "png", diff --git a/src/q+a/views/image-embed.marko b/src/q+a/views/image-embed.marko index 85073d0080d1f4c81c98019418b99195e49fbd82..5b692db81adc3868f301ab6419a145a3e3c52248 100644 --- a/src/q+a/views/image-embed.marko +++ b/src/q+a/views/image-embed.marko @@ -1,4 +1,6 @@ export const meta = { title: "embed image" }; +export { theme } from "../tags/question-layout.marko"; + export interface Input { question: Question; } diff --git a/src/source-of-truth.ts b/src/source-of-truth.ts index d08460381580765e2267bcbe012c03245bd1a74e..0d5d7723b923d5837f93247d11ffd7847e880ace 100644 --- a/src/source-of-truth.ts +++ b/src/source-of-truth.ts @@ -177,6 +177,16 @@ app.get("/progress", (c) => { return c.body(progress.encodeByteStream(indexer.root) as ReadableStream); }); +app.get("/studio/stats", (c) => { + if (c.req.header("Authorization") !== token) return c.json({ error: "invalid authorization header" }, 401); + return c.json(studio.stats(service)); +}); + +app.get("/studio/activity", (c) => { + if (c.req.header("Authorization") !== token) return c.json({ error: "invalid authorization header" }, 401); + return c.json(studio.activity(indexer.root)); +}); + // download a consistent snapshot of cache.sqlite. web nodes call this with // `If-None-Match` on a stale-while-revalidate schedule, and immediately // when pinged at /internal/db-changed. @@ -265,6 +275,7 @@ import * as fs from "#sitegen/fs"; import { Path } from "#sitegen/path"; import { hashFile } from "#src/file-viewer/indexer/scan.ts"; import * as indexer from "#src/file-viewer/indexer/service.ts"; +import * as studio from "#src/file-viewer/indexer/studio.ts"; import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; import { ASSERT, UNWRAP } from "@clo/lib/assert"; diff --git a/src/tags/clover-video.css b/src/tags/clover-video.css index cd5482f311e9bcdbc852e086fc26668916ca3ee2..99ce949aca2a6106ebb94125746b9f09b5d920ff 100644 --- a/src/tags/clover-video.css +++ b/src/tags/clover-video.css @@ -44,3 +44,9 @@ margin-top: -1px; padding-bottom: 2px; } +.clover-video + blockquote { + margin: -18px 0 1rem 0; + border: none; + background-color: var(--primary); + color: black; +} -- 2.54.0