| author | |
| committer | |
| log | 4ceca6c30ef19c14cd4ba76a3cbd9e894f91fdc6 |
| tree | 7d04e345dbdc24d50ca20230dc8ab076e6940f41 |
| parent | ac3b5bd9b0adad806543db8bd217570bb7630313 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
13 files changed, 243 insertions(+), 34 deletions(-)
framework/sitegen-wrap.marko+4-1| ... | ... | @@ -1,2 +1,5 @@ |
| 1 | <const/_=$global.cloverScripts = input.cloverScripts> | |
| 1 | <const/_=(() => { | |
| 2 | $global.cloverScripts = input.cloverScripts; | |
| 3 | $global.cloverViewProps = input.attrs; | |
| 4 | })()> | |
| 2 | 5 | <${input.page} ...input.attrs/> |
lib/progress.ts+1-1| ... | ... | @@ -1857,8 +1857,8 @@ export const internals: { |
| 1857 | 1857 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1858 | 1858 | import * as async from "./async.ts"; |
| 1859 | 1859 | import * as bytes from "./bytes.ts"; |
| 1860 | import * as exceptions from "./exception.ts"; | |
| 1861 | 1860 | import { Events } from "./Events.ts"; |
| 1861 | import * as exceptions from "./exception.ts"; | |
| 1862 | 1862 | import * as log from "./log.ts"; |
| 1863 | 1863 | import * as stack from "./log/stack.ts"; |
| 1864 | 1864 | import * as stream from "./stream.ts"; |
src/file-viewer/indexer/service.ts+10-2| ... | ... | @@ -61,6 +61,7 @@ export class Service { |
| 61 | 61 | sweepIntervalMs: number; |
| 62 | 62 | watchEnabled: boolean; |
| 63 | 63 | watcher: fs.FSWatcher | null = null; |
| 64 | lastSweep: { startedAt: number; endedAt: number | null; error: string | null } | null = null; | |
| 64 | 65 | |
| 65 | 66 | // paths that changed according to the watcher, waiting to be scanned |
| 66 | 67 | #dirty = new Map<string, number>(); |
| ... | ... | @@ -100,7 +101,10 @@ export class Service { |
| 100 | 101 | return; |
| 101 | 102 | } |
| 102 | 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 | 108 | const target = subPath |
| 105 | 109 | ? this.scanner.root.join("." + path.posix.normalize("/" + subPath)) |
| 106 | 110 | : this.scanner.root; |
| ... | ... | @@ -108,9 +112,13 @@ export class Service { |
| 108 | 112 | this.#runDirMeta(); |
| 109 | 113 | if (!subPath) await this.#maintenance(); |
| 110 | 114 | } catch (err) { |
| 115 | if (!subPath && this.lastSweep) this.lastSweep.error = String(err); | |
| 111 | 116 | console.error(`${reason} scan failed:`, err); |
| 112 | 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 | } |
| 116 | 124 |
src/file-viewer/indexer/studio.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; | |
| 2 | import * as mime from "@clo/lib/mime"; | |
| 3 | import * as progress from "@clo/lib/progress"; | |
| 4 | import * as registry from "./registry.ts"; | |
| 5 | import type { Service } from "./service.ts"; | |
| 6 | ||
| 7 | type 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 | }; | |
| 16 | type StateRow = { file: number; name: string; version: number; status: number; updated: number; error: string | null }; | |
| 17 | ||
| 18 | const seconds = (value: number | string) => (typeof value === "string" ? Date.parse(value) : value) / 1000; | |
| 19 | const 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 | ||
| 25 | export 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 | ||
| 125 | const firstSeen = new WeakMap<progress.ReadOnlyNode, number>(); | |
| 126 | ||
| 127 | export 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 | 1 | // This canvas is based on the maze generation algo in Tanks. This was |
| 2 | 2 | // 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 | 5 | // The main difference is that this version is a visualization, rather than the |
| 6 | 6 | // practical function. Instead of taking a millisecond, only 5 steps are |
src/friend-auth.ts+53-18| ... | ... | @@ -1,7 +1,15 @@ |
| 1 | let hardcoded = { | |
| 2 | friendPassword: "", | |
| 3 | monthlyPasswords: [] as Array<{ password: string; start: string }>, | |
| 4 | getForFile: (_: string) => [] as string[], | |
| 1 | interface Grant { | |
| 2 | password: string; | |
| 3 | start: string; | |
| 4 | end: string | null; | |
| 5 | group?: string; | |
| 6 | } | |
| 7 | ||
| 8 | let hardcoded: { | |
| 9 | monthlyPasswords: readonly Grant[]; | |
| 10 | getForFile?: (file: string) => string[]; | |
| 11 | } = { | |
| 12 | monthlyPasswords: [], | |
| 5 | 13 | }; |
| 6 | 14 | try { |
| 7 | 15 | hardcoded = require("./friends/hardcoded-password.ts"); |
| ... | ... | @@ -14,7 +22,7 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month |
| 14 | 22 | |
| 15 | 23 | export const getForFile = hardcoded.getForFile ?? (() => []); |
| 16 | 24 | |
| 17 | function getFriendsCookiePassword(c: Context, passwords: string[]) { | |
| 25 | function getFriendsCookiePassword(c: Context, passwords: readonly string[]) { | |
| 18 | 26 | const cookie = c.req.header("Cookie"); |
| 19 | 27 | if (!cookie) return null; |
| 20 | 28 | const cookies = cookie.split("; ").map((x) => x.split("=")); |
| ... | ... | @@ -29,9 +37,9 @@ function getFriendsCookiePassword(c: Context, passwords: string[]) { |
| 29 | 37 | |
| 30 | 38 | export function requireFriendAuth( |
| 31 | 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 | 43 | return serveAsset(c, "/friends/misconfigure", 501); |
| 36 | 44 | } |
| 37 | 45 | const k = c.req.query("password") || c.req.query("k"); |
| ... | ... | @@ -59,8 +67,29 @@ let incorrectMap: Record<string, boolean> = {}; |
| 59 | 67 | app.use(friendAuthMiddleware); |
| 60 | 68 | |
| 61 | 69 | app.get("/friends", (c) => { |
| 70 | const grant = getGrant(c, getForRoute(c.req.path)); | |
| 62 | 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 | ||
| 78 | app.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 | ||
| 85 | app.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 | }); |
| 66 | 95 | |
| ... | ... | @@ -94,7 +123,7 @@ async function friendAuthMiddleware(c: Context, next: Next) { |
| 94 | 123 | } |
| 95 | 124 | |
| 96 | 125 | function getForRoute(route: string) { |
| 97 | const passwords = [hardcoded.friendPassword]; | |
| 126 | const passwords: string[] = []; | |
| 98 | 127 | const month = getRouteMonth(route); |
| 99 | 128 | if (route === "/friends") { |
| 100 | 129 | passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password)); |
| ... | ... | @@ -102,22 +131,28 @@ function getForRoute(route: string) { |
| 102 | 131 | } |
| 103 | 132 | if (!month) return passwords; |
| 104 | 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 | 138 | return passwords; |
| 108 | 139 | } |
| 109 | 140 | |
| 110 | function getMinimumDate(c: Context) { | |
| 111 | const password = getFriendsCookiePassword(c, getForRoute(c.req.path)); | |
| 112 | if (!password || password === hardcoded.friendPassword) return null; | |
| 113 | const grant = hardcoded.monthlyPasswords.find((grant) => grant.password === password); | |
| 114 | return grant?.start ?? null; | |
| 141 | function getGrant(c: Context, passwords: readonly string[]) { | |
| 142 | const password = getFriendsCookiePassword(c, passwords); | |
| 143 | if (!password || !passwords.includes(password)) return null; | |
| 144 | return hardcoded.monthlyPasswords.find((grant) => grant.password === password) ?? null; | |
| 115 | 145 | } |
| 116 | 146 | |
| 117 | 147 | function getRouteMonth(route: string) { |
| 118 | const [, year, month] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d)-/) ?? []; | |
| 119 | if (!year || !month) return null; | |
| 120 | return `20${year}-${month}`; | |
| 148 | const entry = getRouteEntry(route); | |
| 149 | if (!entry) return null; | |
| 150 | return `20${entry.year}-${entry.name.slice(0, 2)}`; | |
| 151 | } | |
| 152 | ||
| 153 | function 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 | } |
| 122 | 157 | |
| 123 | 158 | function isFriendAuthPage(route: string) { |
src/pages/admin/index.mdo+1-1| ... | ... | @@ -4,7 +4,7 @@ meta: |
| 4 | 4 | --- |
| 5 | 5 | <main> |
| 6 | 6 | |
| 7 | # hi chloe | |
| 7 | # hi snow | |
| 8 | 8 | |
| 9 | 9 | remember that you're loved. |
| 10 | 10 |
src/pages/index.marko+2-2| ... | ... | @@ -44,9 +44,9 @@ export const meta = { |
| 44 | 44 | <h2>things</h2> |
| 45 | 45 | <ul class="section-list"> |
| 46 | 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 | 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 | 50 | <li><a href="/dream">my life's dream</a></li> |
| 51 | 51 | <li><a href="/subscribe">mailing list</a></li> |
| 52 | 52 | </ul> |
src/pages/waterfalls.marko+2-6| ... | ... | @@ -11,12 +11,8 @@ export const theme = { |
| 11 | 11 | <main class="waterfalls"> |
| 12 | 12 | <div class="contain"> |
| 13 | 13 | <nav><a href="/">back to home</a></nav> |
| 14 | <h1>waterfalls and gender identity update</h1> | |
| 15 | <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> | |
| 14 | <h1>waterfalls</h1> | |
| 15 | <p>this is a song about identity, jealousness, and the seasons. enjoy.</p> | |
| 20 | 16 | </div> |
| 21 | 17 | <clover-video |
| 22 | 18 | file="/2025/waterfalls/waterfalls.mp4" |
src/q+a/image.ts+2-2| ... | ... | @@ -19,14 +19,14 @@ export async function renderQuestionImage(question: Question) { |
| 19 | 19 | }); |
| 20 | 20 | try { |
| 21 | 21 | const p = await ctx.newPage(); |
| 22 | await p.setViewportSize({ width, height: 400 }); | |
| 22 | await p.setViewportSize({ width, height: 15000 }); | |
| 23 | 23 | await p.goto(`http://localhost:${port}/q+a/${question.id}`); |
| 24 | 24 | try { |
| 25 | 25 | await p.waitForLoadState("networkidle", { timeout: 500 }); |
| 26 | 26 | } catch {} |
| 27 | 27 | const height = await p.evaluate(() => { |
| 28 | 28 | document.body.style.background = "#1a1b1c"; |
| 29 | return document.querySelector("main")!.getBoundingClientRect().height; | |
| 29 | return document.querySelector("main")!.offsetHeight; | |
| 30 | 30 | }); |
| 31 | 31 | return await p.screenshot({ |
| 32 | 32 | type: "png", |
src/q+a/views/image-embed.marko+2| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | export const meta = { title: "embed image" }; |
| 2 | export { theme } from "../tags/question-layout.marko"; | |
| 3 | ||
| 2 | 4 | export interface Input { |
| 3 | 5 | question: Question; |
| 4 | 6 | } |
src/source-of-truth.ts+11| ... | ... | @@ -177,6 +177,16 @@ app.get("/progress", (c) => { |
| 177 | 177 | return c.body(progress.encodeByteStream(indexer.root) as ReadableStream); |
| 178 | 178 | }); |
| 179 | 179 | |
| 180 | app.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 | ||
| 185 | app.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 | 190 | // download a consistent snapshot of cache.sqlite. web nodes call this with |
| 181 | 191 | // `If-None-Match` on a stale-while-revalidate schedule, and immediately |
| 182 | 192 | // when pinged at /internal/db-changed. |
| ... | ... | @@ -265,6 +275,7 @@ import * as fs from "#sitegen/fs"; |
| 265 | 275 | import { Path } from "#sitegen/path"; |
| 266 | 276 | import { hashFile } from "#src/file-viewer/indexer/scan.ts"; |
| 267 | 277 | import * as indexer from "#src/file-viewer/indexer/service.ts"; |
| 278 | import * as studio from "#src/file-viewer/indexer/studio.ts"; | |
| 268 | 279 | import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; |
| 269 | 280 | import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; |
| 270 | 281 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
src/tags/clover-video.css+6| ... | ... | @@ -44,3 +44,9 @@ |
| 44 | 44 | margin-top: -1px; |
| 45 | 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 | } |