authorclover caruso <git@paperclover.net> 2026-07-13 23:31:34-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 11:20:39-07:00
log4ceca6c30ef19c14cd4ba76a3cbd9e894f91fdc6
tree7d04e345dbdc24d50ca20230dc8ab076e6940f41
parentac3b5bd9b0adad806543db8bd217570bb7630313
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

chore: random things


13 files changed, 243 insertions(+), 34 deletions(-)

framework/sitegen-wrap.marko+4-1
...@@ -1,2 +1,5 @@...@@ -1,2 +1,5 @@
1<const/_=$global.cloverScripts = input.cloverScripts>1<const/_=(() => {
2 $global.cloverScripts = input.cloverScripts;
3 $global.cloverViewProps = input.attrs;
4})()>
2<${input.page} ...input.attrs/>5<${input.page} ...input.attrs/>
lib/progress.ts+1-1
...@@ -1857,8 +1857,8 @@ export const internals: {...@@ -1857,8 +1857,8 @@ export const internals: {
1857import { ASSERT, UNWRAP } from "./assert.ts";1857import { ASSERT, UNWRAP } from "./assert.ts";
1858import * as async from "./async.ts";1858import * as async from "./async.ts";
1859import * as bytes from "./bytes.ts";1859import * as bytes from "./bytes.ts";
1860import * as exceptions from "./exception.ts";
1861import { Events } from "./Events.ts";1860import { Events } from "./Events.ts";
1861import * as exceptions from "./exception.ts";
1862import * as log from "./log.ts";1862import * as log from "./log.ts";
1863import * as stack from "./log/stack.ts";1863import * as stack from "./log/stack.ts";
1864import * as stream from "./stream.ts";1864import * as stream from "./stream.ts";
src/file-viewer/indexer/service.ts+10-2
...@@ -61,6 +61,7 @@ export class Service {...@@ -61,6 +61,7 @@ export class Service {
61 sweepIntervalMs: number;61 sweepIntervalMs: number;
62 watchEnabled: boolean;62 watchEnabled: boolean;
63 watcher: fs.FSWatcher | null = null;63 watcher: fs.FSWatcher | null = null;
64 lastSweep: { startedAt: number; endedAt: number | null; error: string | null } | null = null;
6465
65 // paths that changed according to the watcher, waiting to be scanned66 // paths that changed according to the watcher, waiting to be scanned
66 #dirty = new Map<string, number>();67 #dirty = new Map<string, number>();
...@@ -100,7 +101,10 @@ export class Service {...@@ -100,7 +101,10 @@ export class Service {
100 return;101 return;
101 }102 }
102 try {103 try {
103 if (!subPath) this.#sweeping = true;104 if (!subPath) {
105 this.#sweeping = true;
106 this.lastSweep = { startedAt: Date.now() / 1000, endedAt: null, error: null };
107 }
104 const target = subPath108 const target = subPath
105 ? this.scanner.root.join("." + path.posix.normalize("/" + subPath))109 ? this.scanner.root.join("." + path.posix.normalize("/" + subPath))
106 : this.scanner.root;110 : this.scanner.root;
...@@ -108,9 +112,13 @@ export class Service {...@@ -108,9 +112,13 @@ export class Service {
108 this.#runDirMeta();112 this.#runDirMeta();
109 if (!subPath) await this.#maintenance();113 if (!subPath) await this.#maintenance();
110 } catch (err) {114 } catch (err) {
115 if (!subPath && this.lastSweep) this.lastSweep.error = String(err);
111 console.error(`${reason} scan failed:`, err);116 console.error(`${reason} scan failed:`, err);
112 } finally {117 } finally {
113 if (!subPath) this.#sweeping = false;118 if (!subPath) {
119 this.#sweeping = false;
120 if (this.lastSweep) this.lastSweep.endedAt = Date.now() / 1000;
121 }
114 }122 }
115 }123 }
116124
src/file-viewer/indexer/studio.ts created+148
...@@ -0,0 +1,148 @@
1import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
2import * as mime from "@clo/lib/mime";
3import * as progress from "@clo/lib/progress";
4import * as registry from "./registry.ts";
5import type { Service } from "./service.ts";
6
7type FileRow = {
8 id: number;
9 path: string;
10 kind: number;
11 size: number;
12 timestamp_updated: number | string;
13 duration: number;
14 dimensions: string;
15};
16type StateRow = { file: number; name: string; version: number; status: number; updated: number; error: string | null };
17
18const seconds = (value: number | string) => (typeof value === "string" ? Date.parse(value) : value) / 1000;
19const extension = (path: string) => {
20 const name = path.split("/").at(-1) ?? "";
21 const dot = name.lastIndexOf(".");
22 return (dot > 0 ? name.slice(dot) : name).toLowerCase();
23};
24
25export function stats(service: Service | null) {
26 const db = MediaFile.db.node;
27 const all = db.prepare("select id,path,kind,size,timestamp_updated,duration,dimensions from media_files")
28 .all() as FileRow[];
29 const files = all.filter((file) => file.kind === 1);
30 const extensions = new Map(files.map((file) => [file.id, extension(file.path)]));
31 const recorded = db.prepare(`
32 select fp.file,p.name,fp.version,fp.status,fp.updated,fp.error
33 from file_processors fp join processors p on p.id=fp.processor
34 `).all() as StateRow[];
35 const byFile = new Map<number, Map<string, StateRow>>();
36 for (const row of recorded) {
37 const states = byFile.get(row.file) ?? new Map<string, StateRow>();
38 states.set(row.name, row);
39 byFile.set(row.file, states);
40 }
41 const folders = new Map<string, { name: string; files: number; bytes: number }>();
42 const types = new Map<string, { ext: string; files: number; bytes: number }>();
43 for (const file of files) {
44 const top = file.path.split("/")[1] ?? "";
45 const folder = folders.get(top) ?? { name: top, files: 0, bytes: 0 };
46 folder.files++;
47 folder.bytes += file.size;
48 folders.set(top, folder);
49 const ext = extensions.get(file.id) ?? "";
50 const type = types.get(ext) ?? { ext, files: 0, bytes: 0 };
51 type.files++;
52 type.bytes += file.size;
53 types.set(ext, type);
54 }
55 const processors = registry.processors.map((p) => {
56 let applicable = 0;
57 let done = 0;
58 let failed = 0;
59 let stale = 0;
60 for (const file of files) {
61 const ext = extensions.get(file.id) ?? "";
62 if (p.include ? !p.include.has(ext) : p.exclude?.has(ext)) continue;
63 applicable++;
64 const state = byFile.get(file.id)?.get(p.name);
65 if (state?.version !== p.version) {
66 if (state) stale++;
67 } else if (state.status === 1) done++;
68 else if (state.status === 2) failed++;
69 }
70 return {
71 name: p.name,
72 title: p.title,
73 version: p.version,
74 runnable: registry.canExecute(p),
75 applicable,
76 done,
77 failed,
78 stale,
79 outputs: null,
80 bytes: null,
81 seconds: null,
82 };
83 });
84 const failures = files.flatMap((file) => {
85 const states = [...(byFile.get(file.id)?.values() ?? [])];
86 const errors = states.flatMap((state) =>
87 state.status === 2 && state.error !== null
88 ? [{ processor: state.name, error: state.error, at: state.updated / 1000 }]
89 : []
90 );
91 return errors.length
92 ? [{
93 path: file.path,
94 size: file.size,
95 mime: mime.for(file.path),
96 made: states.filter((state) => state.status === 1).map((state) => state.name),
97 errors,
98 }]
99 : [];
100 });
101 const recent = [...files].sort((a, b) => seconds(b.timestamp_updated) - seconds(a.timestamp_updated))
102 .slice(0, 12).map((file) => ({ path: file.path, size: file.size, at: seconds(file.timestamp_updated) }));
103 const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 12)
104 .map((file) => ({ path: file.path, size: file.size, duration: file.duration, dimensions: file.dimensions }));
105 const derived = db.prepare("select count(*) as files,coalesce(sum(size),0) as bytes from derived_files").get() as {
106 files: number;
107 bytes: number;
108 };
109 return {
110 indexer: { enabled: service !== null, watching: Boolean(service?.watcher), nextSweepAt: null },
111 lastSweep: service?.lastSweep ?? null,
112 files: files.length,
113 directories: all.length - files.length,
114 bytes: files.reduce((sum, file) => sum + file.size, 0),
115 folders: [...folders.values()].sort((a, b) => b.bytes - a.bytes),
116 types: [...types.values()].sort((a, b) => b.bytes - a.bytes),
117 processors,
118 derived,
119 failures,
120 recent,
121 largest,
122 };
123}
124
125const firstSeen = new WeakMap<progress.ReadOnlyNode, number>();
126
127export function activity(root: progress.Root) {
128 const convert = (node: progress.ReadOnlyNode): object => {
129 if (!firstSeen.has(node)) firstSeen.set(node, Date.now() / 1000);
130 const match = /^(\/.*?) - (.*)$/.exec(node.text);
131 const children = (node.sortChildren ? [...node.children].sort(node.sortChildren) : node.children)
132 .filter((child) => !child.hidden && (!child.passive || child.children.length)).map(convert);
133 return {
134 path: match?.[1] ?? null,
135 text: match?.[2] ?? node.text,
136 count: node.valueFormatter(node.value, node.total),
137 progress: node.showTotal && node.total && node.total > 0 ? node.value / node.total : null,
138 eta: node.estimatedTime ? node.estimatedTime / 1000 : null,
139 since: firstSeen.get(node),
140 logs: node.logs.slice(-3).map((entry) => ({
141 level: entry.level ?? "info",
142 text: entry.text.replace(/\x1b\[[\d;]*m/g, ""),
143 })),
144 children,
145 };
146 };
147 return root.active.filter((node) => !node.hidden && (!node.passive || node.children.length)).map(convert);
148}
src/file-viewer/scripts/canvas_2018.client.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1// This canvas is based on the maze generation algo in Tanks. This was1// This canvas is based on the maze generation algo in Tanks. This was
2// originally written in C++ as a single function in 2018, and was ported to TS2// originally written in C++ as a single function in 2018, and was ported to TS
3// by Chloe in 2025 for the cotyledon canvas.3// by Clover in 2025 for the cotyledon canvas.
4//4//
5// The main difference is that this version is a visualization, rather than the5// The main difference is that this version is a visualization, rather than the
6// practical function. Instead of taking a millisecond, only 5 steps are6// practical function. Instead of taking a millisecond, only 5 steps are
src/friend-auth.ts+53-18
...@@ -1,7 +1,15 @@...@@ -1,7 +1,15 @@
1let hardcoded = {1interface Grant {
2 friendPassword: "",2 password: string;
3 monthlyPasswords: [] as Array<{ password: string; start: string }>,3 start: string;
4 getForFile: (_: string) => [] as string[],4 end: string | null;
5 group?: string;
6}
7
8let hardcoded: {
9 monthlyPasswords: readonly Grant[];
10 getForFile?: (file: string) => string[];
11} = {
12 monthlyPasswords: [],
5};13};
6try {14try {
7 hardcoded = require("./friends/hardcoded-password.ts");15 hardcoded = require("./friends/hardcoded-password.ts");
...@@ -14,7 +22,7 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month...@@ -14,7 +22,7 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month
1422
15export const getForFile = hardcoded.getForFile ?? (() => []);23export const getForFile = hardcoded.getForFile ?? (() => []);
1624
17function getFriendsCookiePassword(c: Context, passwords: string[]) {25function getFriendsCookiePassword(c: Context, passwords: readonly string[]) {
18 const cookie = c.req.header("Cookie");26 const cookie = c.req.header("Cookie");
19 if (!cookie) return null;27 if (!cookie) return null;
20 const cookies = cookie.split("; ").map((x) => x.split("="));28 const cookies = cookie.split("; ").map((x) => x.split("="));
...@@ -29,9 +37,9 @@ function getFriendsCookiePassword(c: Context, passwords: string[]) {...@@ -29,9 +37,9 @@ function getFriendsCookiePassword(c: Context, passwords: string[]) {
2937
30export function requireFriendAuth(38export function requireFriendAuth(
31 c: Context,39 c: Context,
32 passwords = [hardcoded.friendPassword],40 passwords: readonly string[] = [],
33) {41) {
34 if (passwords.length === 0 || passwords[0]!.length === 0) {42 if (passwords.length === 0) {
35 return serveAsset(c, "/friends/misconfigure", 501);43 return serveAsset(c, "/friends/misconfigure", 501);
36 }44 }
37 const k = c.req.query("password") || c.req.query("k");45 const k = c.req.query("password") || c.req.query("k");
...@@ -59,8 +67,29 @@ let incorrectMap: Record<string, boolean> = {};...@@ -59,8 +67,29 @@ let incorrectMap: Record<string, boolean> = {};
59app.use(friendAuthMiddleware);67app.use(friendAuthMiddleware);
6068
61app.get("/friends", (c) => {69app.get("/friends", (c) => {
70 const grant = getGrant(c, getForRoute(c.req.path));
62 return view.serve(c, "friends/index", {71 return view.serve(c, "friends/index", {
63 minimumDate: getMinimumDate(c),72 minimumDate: grant?.start ?? null,
73 maximumDate: grant?.end ?? null,
74 group: grant?.group ?? "default",
75 });
76});
77
78app.post("/friends/logout", (c) => {
79 return c.body(null, 303, {
80 Location: "/friends",
81 "Set-Cookie": "friends_password=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
82 });
83});
84
85app.get("/friends/:year/:entry", (c) => {
86 const entry = getRouteEntry(c.req.path);
87 if (!entry) return c.notFound();
88 const grant = getGrant(c, getForRoute(c.req.path));
89 return view.serve(c, `friends/entries/${entry.year}/${entry.name}` as view.Key, {
90 minimumDate: grant?.start ?? null,
91 maximumDate: grant?.end ?? null,
92 group: grant?.group ?? "default",
64 });93 });
65});94});
6695
...@@ -94,7 +123,7 @@ async function friendAuthMiddleware(c: Context, next: Next) {...@@ -94,7 +123,7 @@ async function friendAuthMiddleware(c: Context, next: Next) {
94}123}
95124
96function getForRoute(route: string) {125function getForRoute(route: string) {
97 const passwords = [hardcoded.friendPassword];126 const passwords: string[] = [];
98 const month = getRouteMonth(route);127 const month = getRouteMonth(route);
99 if (route === "/friends") {128 if (route === "/friends") {
100 passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password));129 passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password));
...@@ -102,22 +131,28 @@ function getForRoute(route: string) {...@@ -102,22 +131,28 @@ function getForRoute(route: string) {
102 }131 }
103 if (!month) return passwords;132 if (!month) return passwords;
104 for (const grant of hardcoded.monthlyPasswords) {133 for (const grant of hardcoded.monthlyPasswords) {
105 if (month >= grant.start) passwords.push(grant.password);134 if (month >= grant.start && (grant.end == null || month <= grant.end)) {
135 passwords.push(grant.password);
136 }
106 }137 }
107 return passwords;138 return passwords;
108}139}
109140
110function getMinimumDate(c: Context) {141function getGrant(c: Context, passwords: readonly string[]) {
111 const password = getFriendsCookiePassword(c, getForRoute(c.req.path));142 const password = getFriendsCookiePassword(c, passwords);
112 if (!password || password === hardcoded.friendPassword) return null;143 if (!password || !passwords.includes(password)) return null;
113 const grant = hardcoded.monthlyPasswords.find((grant) => grant.password === password);144 return hardcoded.monthlyPasswords.find((grant) => grant.password === password) ?? null;
114 return grant?.start ?? null;
115}145}
116146
117function getRouteMonth(route: string) {147function getRouteMonth(route: string) {
118 const [, year, month] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d)-/) ?? [];148 const entry = getRouteEntry(route);
119 if (!year || !month) return null;149 if (!entry) return null;
120 return `20${year}-${month}`;150 return `20${entry.year}-${entry.name.slice(0, 2)}`;
151}
152
153function getRouteEntry(route: string) {
154 const [, year, name] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d-[a-z]{3})$/) ?? [];
155 return year && name ? { year, name } : null;
121}156}
122157
123function isFriendAuthPage(route: string) {158function isFriendAuthPage(route: string) {
src/pages/admin/index.mdo+1-1
...@@ -4,7 +4,7 @@ meta:...@@ -4,7 +4,7 @@ meta:
4---4---
5<main>5<main>
66
7# hi chloe7# hi snow
88
9remember that you're loved.9remember that you're loved.
1010
src/pages/index.marko+2-2
...@@ -44,9 +44,9 @@ export const meta = {...@@ -44,9 +44,9 @@ export const meta = {
44 <h2>things</h2>44 <h2>things</h2>
45 <ul class="section-list">45 <ul class="section-list">
46 <li><a href="/q+a">questions and answers</a></li>46 <li><a href="/q+a">questions and answers</a></li>
47 <li><a href="/log">recent activity</a></li>47 // <li><a href="/log">recent activity</a></li>
48 <li><a href="/file">file browser</a></li>48 <li><a href="/file">file browser</a></li>
49 <li><a href="https://git.paperclover.net/clo">my software</a></li>49 <li><a href="https://shale.paperclover.net/clo">my software</a></li>
50 <li><a href="/dream">my life's dream</a></li>50 <li><a href="/dream">my life's dream</a></li>
51 <li><a href="/subscribe">mailing list</a></li>51 <li><a href="/subscribe">mailing list</a></li>
52 </ul>52 </ul>
src/pages/waterfalls.marko+2-6
...@@ -11,12 +11,8 @@ export const theme = {...@@ -11,12 +11,8 @@ export const theme = {
11 <main class="waterfalls">11 <main class="waterfalls">
12 <div class="contain">12 <div class="contain">
13 <nav><a href="/">back to home</a></nav>13 <nav><a href="/">back to home</a></nav>
14 <h1>waterfalls and gender identity update</h1>14 <h1>waterfalls</h1>
15 <p>15 <p>this is a song about identity, jealousness, and the seasons. enjoy.</p>
16 this is a song about identity, jealousness, and the seasons. more
17 specifically, it's a personal account of how it felt realizing i was a
18 transgender woman. enjoy.
19 </p>
20 </div>16 </div>
21 <clover-video17 <clover-video
22 file="/2025/waterfalls/waterfalls.mp4"18 file="/2025/waterfalls/waterfalls.mp4"
src/q+a/image.ts+2-2
...@@ -19,14 +19,14 @@ export async function renderQuestionImage(question: Question) {...@@ -19,14 +19,14 @@ export async function renderQuestionImage(question: Question) {
19 });19 });
20 try {20 try {
21 const p = await ctx.newPage();21 const p = await ctx.newPage();
22 await p.setViewportSize({ width, height: 400 });22 await p.setViewportSize({ width, height: 15000 });
23 await p.goto(`http://localhost:${port}/q+a/${question.id}`);23 await p.goto(`http://localhost:${port}/q+a/${question.id}`);
24 try {24 try {
25 await p.waitForLoadState("networkidle", { timeout: 500 });25 await p.waitForLoadState("networkidle", { timeout: 500 });
26 } catch {}26 } catch {}
27 const height = await p.evaluate(() => {27 const height = await p.evaluate(() => {
28 document.body.style.background = "#1a1b1c";28 document.body.style.background = "#1a1b1c";
29 return document.querySelector("main")!.getBoundingClientRect().height;29 return document.querySelector("main")!.offsetHeight;
30 });30 });
31 return await p.screenshot({31 return await p.screenshot({
32 type: "png",32 type: "png",
src/q+a/views/image-embed.marko+2
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1export const meta = { title: "embed image" };1export const meta = { title: "embed image" };
2export { theme } from "../tags/question-layout.marko";
3
2export interface Input {4export interface Input {
3 question: Question;5 question: Question;
4}6}
src/source-of-truth.ts+11
...@@ -177,6 +177,16 @@ app.get("/progress", (c) => {...@@ -177,6 +177,16 @@ app.get("/progress", (c) => {
177 return c.body(progress.encodeByteStream(indexer.root) as ReadableStream);177 return c.body(progress.encodeByteStream(indexer.root) as ReadableStream);
178});178});
179179
180app.get("/studio/stats", (c) => {
181 if (c.req.header("Authorization") !== token) return c.json({ error: "invalid authorization header" }, 401);
182 return c.json(studio.stats(service));
183});
184
185app.get("/studio/activity", (c) => {
186 if (c.req.header("Authorization") !== token) return c.json({ error: "invalid authorization header" }, 401);
187 return c.json(studio.activity(indexer.root));
188});
189
180// download a consistent snapshot of cache.sqlite. web nodes call this with190// download a consistent snapshot of cache.sqlite. web nodes call this with
181// `If-None-Match` on a stale-while-revalidate schedule, and immediately191// `If-None-Match` on a stale-while-revalidate schedule, and immediately
182// when pinged at /internal/db-changed.192// when pinged at /internal/db-changed.
...@@ -265,6 +275,7 @@ import * as fs from "#sitegen/fs";...@@ -265,6 +275,7 @@ import * as fs from "#sitegen/fs";
265import { Path } from "#sitegen/path";275import { Path } from "#sitegen/path";
266import { hashFile } from "#src/file-viewer/indexer/scan.ts";276import { hashFile } from "#src/file-viewer/indexer/scan.ts";
267import * as indexer from "#src/file-viewer/indexer/service.ts";277import * as indexer from "#src/file-viewer/indexer/service.ts";
278import * as studio from "#src/file-viewer/indexer/studio.ts";
268import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";279import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
269import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";280import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
270import { ASSERT, UNWRAP } from "@clo/lib/assert";281import { ASSERT, UNWRAP } from "@clo/lib/assert";
src/tags/clover-video.css+6
...@@ -44,3 +44,9 @@...@@ -44,3 +44,9 @@
44 margin-top: -1px;44 margin-top: -1px;
45 padding-bottom: 2px;45 padding-bottom: 2px;
46}46}
47.clover-video + blockquote {
48 margin: -18px 0 1rem 0;
49 border: none;
50 background-color: var(--primary);
51 color: black;
52}