rvlib-shipkit 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,110 @@
1
+ // a repo's review unit at work (wiki build/shipkit/review.md, A project's parts): its file found by a walk cached on the
2
+ // walked dirs' times and its text, imported, its spec bound to the store; its sources pulled, its kinds' facts and data
3
+ // view computed on the server, so the hub never bundles project code
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
7
+ import { LAYOUT } from '../core/layout.ts'
8
+ import type { ReviewItem } from './model.ts'
9
+ import { bindSpec, readAll, storeDir } from './store.ts'
10
+ import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewText } from './unit.ts'
11
+
12
+ /** the walk's answer kept in the cache: the unit files and every dir it read, each with its time; a dir whose time moved
13
+ * means a file came or went, so the walk runs again */
14
+ interface Found {
15
+ dirs: [string, number][]
16
+ files: [string, number, boolean][]
17
+ }
18
+
19
+ const foundFile = (root: string): string => join(root, LAYOUT.cache, 'review-unit.json')
20
+ const mtime = (p: string): number => statSync(p, { throwIfNoEntry: false })?.mtimeMs ?? -1
21
+
22
+ /** the unit files that declare a review unit, by a text test, never an import: the walk's dirs cached on their times */
23
+ export function reviewUnitFiles(root: string): string[] {
24
+ const cache = foundFile(root)
25
+ let found: Found | null = null
26
+ try {
27
+ found = JSON.parse(readFileSync(cache, 'utf8')) as Found
28
+ } catch {
29
+ found = null
30
+ }
31
+ const fresh = (f: Found): boolean => f.dirs.every(([d, t]) => mtime(d) === t) && f.files.every(([p, t]) => mtime(p) === t)
32
+ if (!found || !fresh(found)) {
33
+ const dirs: string[] = []
34
+ const files = repoUnitFiles(root, dirs).filter((f) => !f.endsWith('.check.shipkit.ts'))
35
+ found = { dirs: dirs.map((d) => [d, mtime(d)]), files: files.map((f) => [f, mtime(f), /\breview\s*\(/.test(readFileSync(f, 'utf8'))]) }
36
+ try {
37
+ mkdirSync(join(root, LAYOUT.cache), { recursive: true })
38
+ writeFileSync(`${cache}.${process.pid}.tmp`, JSON.stringify(found))
39
+ renameSync(`${cache}.${process.pid}.tmp`, cache)
40
+ } catch {
41
+ // a read only checkout: the next run walks again
42
+ }
43
+ }
44
+ return found.files.filter(([, , has]) => has).map(([f]) => f)
45
+ }
46
+
47
+ /** the repo's review unit, or null; two of them is an error naming both files */
48
+ export async function loadReviewUnit(root: string): Promise<ReviewUnit | null> {
49
+ if (!existsSync(root)) return null
50
+ const units: { file: string; unit: ReviewUnit }[] = []
51
+ for (const f of reviewUnitFiles(root)) {
52
+ const d = (await importFresh(f)) as { kind?: unknown } | undefined
53
+ if (d?.kind === 'review') units.push({ file: f, unit: d as ReviewUnit })
54
+ }
55
+ if (units.length > 1) throw new Error(`two review units: ${units.map((u) => u.file).join(' and ')}; keep one`)
56
+ return units[0]?.unit ?? null
57
+ }
58
+
59
+ /** a repo's store with its unit read and its spec bound, so every read and write of `dir` checks what the unit declares */
60
+ export async function openReview(root: string): Promise<{ dir: string; unit: ReviewUnit | null }> {
61
+ const unit = await loadReviewUnit(root)
62
+ const dir = storeDir(root)
63
+ bindSpec(dir, specOfUnit(unit))
64
+ return { dir, unit }
65
+ }
66
+
67
+ /** every source pulled once: the ids written; a source that fails says one line through `say`, never stops the others */
68
+ export async function pullSources(dir: string, unit: ReviewUnit | null, say: (line: string) => void): Promise<string[]> {
69
+ const out: string[] = []
70
+ for (const s of unit?.sources ?? []) {
71
+ try {
72
+ out.push(...(await s.pull({ dir, items: readAll(dir).items, say })))
73
+ } catch (e) {
74
+ say(`review: FAIL source ${s.id} not pulled: ${(e as Error).message}`)
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** the lines `show` and `inbox` print under an item's head: its kind's facts, then the facts of the source that raised it */
81
+ export function factLines(unit: ReviewUnit | null, x: ReviewItem): string[] {
82
+ const facts: Fact[] = []
83
+ try {
84
+ facts.push(...(unit?.kinds?.[x.item.subject.kind]?.facts?.(x) ?? []))
85
+ if (x.item.origin.by === 'player') for (const s of unit?.sources ?? []) facts.push(...(s.facts?.(x) ?? []))
86
+ } catch (e) {
87
+ facts.push({ label: 'FAIL', value: `the unit's facts threw: ${(e as Error).message}` })
88
+ }
89
+ return facts.map((f) => ` ${f.label}: ${f.value}`)
90
+ }
91
+
92
+ /** a kind's data view of one item, what the hub draws: its facts, links, live url and text; a function that throws says so as a fact */
93
+ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null } {
94
+ const k = unit?.kinds?.[x.item.subject.kind]
95
+ const tryOr = <T>(what: string, f: (() => T) | undefined, none: T, errors: Fact[]): T => {
96
+ try {
97
+ return f ? f() : none
98
+ } catch (e) {
99
+ errors.push({ label: 'FAIL', value: `${what} threw: ${(e as Error).message}` })
100
+ return none
101
+ }
102
+ }
103
+ const errors: Fact[] = []
104
+ const facts = tryOr('facts', k?.facts && (() => k.facts?.(x) ?? []), [] as Fact[], errors)
105
+ const source = x.item.origin.by === 'player' ? (unit?.sources ?? []).flatMap((s) => tryOr(`source ${s.id} facts`, s.facts && (() => s.facts?.(x) ?? []), [] as Fact[], errors)) : []
106
+ const links = tryOr('links', k?.links && (() => k.links?.(x) ?? []), [] as Link[], errors)
107
+ const live = tryOr<string | null>('live', k?.live && (() => k.live?.(x) ?? null), null, errors)
108
+ const text = tryOr<ViewText | null>('text', k?.text && (() => k.text?.(x) ?? null), null, errors)
109
+ return { facts: [...facts, ...source, ...errors], links, live, text }
110
+ }
@@ -0,0 +1,48 @@
1
+ // the dispatcher role (wiki build/shipkit/review.md, Raised items): one session routes what the owner, the players and
2
+ // the rules raise. Its file sits beside the store, out of the store's watch
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { AGENT_RE } from './model.ts'
6
+
7
+ /** the role's holder, and when it last acted */
8
+ export interface Role {
9
+ name: string
10
+ since: string
11
+ beat: string
12
+ }
13
+
14
+ const roleFile = (dir: string): string => join(dir, '..', 'review-dispatcher.json')
15
+ /** another session may take the role once its holder did nothing for this long */
16
+ export const ROLE_IDLE_MS = 30 * 60 * 1000
17
+
18
+ export function roleOf(dir: string): Role | null {
19
+ try {
20
+ return JSON.parse(readFileSync(roleFile(dir), 'utf8')) as Role
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /** `name` takes the role: refused while another holder acted within ROLE_IDLE_MS, unless `take` */
27
+ export function takeRole(dir: string, name: string, take: boolean, now = new Date()): { ok: true; was: Role | null } | { ok: false; why: string } {
28
+ if (!AGENT_RE.test(name)) return { ok: false, why: `« ${name} » is no agent name (${AGENT_RE.source})` }
29
+ const was = roleOf(dir)
30
+ if (was && was.name !== name && !take && now.getTime() - Date.parse(was.beat) < ROLE_IDLE_MS)
31
+ return { ok: false, why: `${was.name} holds the dispatcher role (active ${Math.round((now.getTime() - Date.parse(was.beat)) / 60000)} min ago): --take to take it over` }
32
+ const at = now.toISOString()
33
+ writeRole(dir, { name, since: was?.name === name ? was.since : at, beat: at })
34
+ return { ok: true, was }
35
+ }
36
+
37
+ /** the holder acted: its beat moves to `now` */
38
+ export function beatRole(dir: string, now: Date): void {
39
+ const r = roleOf(dir)
40
+ if (r) writeRole(dir, { ...r, beat: now.toISOString() })
41
+ }
42
+
43
+ function writeRole(dir: string, r: Role): void {
44
+ const file = roleFile(dir)
45
+ mkdirSync(join(file, '..'), { recursive: true })
46
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(r, null, 4)}\n`)
47
+ renameSync(`${file}.tmp`, file)
48
+ }
@@ -0,0 +1,228 @@
1
+ // the hub's review routes (wiki build/shipkit/review.md, The hub): the store's list, an item's images and clips, the
2
+ // owner's verdicts, replies and read marks appended to its thread. The trailer view's routes: trailer-routes.ts. Paths are
3
+ // relative to the repo hub's base. null: not a path of these
4
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { extname, join } from 'node:path'
7
+ import { isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } from './anim.ts'
8
+ import { startAuto } from './auto.ts'
9
+ import { bad, KEEP, NO_STORE, ranged, TYPES } from './http.ts'
10
+ import type { Rect, Subject } from './model.ts'
11
+ import { openReview, viewOfItem } from './project.ts'
12
+ import { roleOf } from './role.ts'
13
+ import { addMessage, imageFile, markRead, raiseItem, rateSubject, readAllCached, readItem, specOf, storeDir, storeStamp, threadImage, writeVerdict } from './store.ts'
14
+ import { trailerRoutes } from './trailer-routes.ts'
15
+ import { specData, type ReviewUnit } from './unit.ts'
16
+ import { liveWatchers } from './watchers.ts'
17
+
18
+ // ── each repo's review unit, read again at most every 5 s; its auto rules scheduled in this server while it serves ──
19
+
20
+ interface Opened {
21
+ at: number
22
+ unit: ReviewUnit | null
23
+ auto: { stop(): void } | null
24
+ }
25
+ const opened = new Map<string, Opened>()
26
+
27
+ /** the repo's store, its unit bound; a unit that changed restarts its rules. A unit that does not load throws */
28
+ async function project(root: string): Promise<{ dir: string; unit: ReviewUnit | null }> {
29
+ const held = opened.get(root)
30
+ if (held && Date.now() - held.at < 5000) return { dir: storeDir(root), unit: held.unit }
31
+ const { dir, unit } = await openReview(root)
32
+ if (held && held.unit === unit) {
33
+ held.at = Date.now()
34
+ return { dir, unit }
35
+ }
36
+ held?.auto?.stop()
37
+ const rules = unit?.auto ?? []
38
+ opened.set(root, { at: Date.now(), unit, auto: rules.length > 0 ? startAuto(dir, rules, () => readAllCached(dir).items) : null })
39
+ return { dir, unit }
40
+ }
41
+
42
+ /** an image the page sent as a data url, written to a scratch file the caller removes */
43
+ function dataUrlFile(url: unknown): { file: string; done(): void } | null {
44
+ if (url === undefined || url === null) return null
45
+ const m = typeof url === 'string' ? /^data:image\/(png|jpeg|webp);base64,(.+)$/.exec(url) : null
46
+ if (!m) throw new Error('image: a png, jpeg or webp data url')
47
+ const tmp = mkdtempSync(join(tmpdir(), 'review-raise-'))
48
+ const file = join(tmp, `image.${m[1] === 'jpeg' ? 'jpg' : m[1]}`)
49
+ writeFileSync(file, Buffer.from(m[2] ?? '', 'base64'))
50
+ return { file, done: () => rmSync(tmp, { recursive: true, force: true }) }
51
+ }
52
+
53
+ const obj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
54
+
55
+ // ── the list's thumbnails: a 480 px webp of an item's image, cached beside the items (readAll skips dot names) ──
56
+
57
+ const THUMB_W = 480
58
+ const THUMB_JOBS = 3
59
+ let running = 0
60
+ const waiting: (() => void)[] = []
61
+ const making = new Map<string, Promise<string | null>>()
62
+
63
+ async function slot<T>(work: () => Promise<T>): Promise<T> {
64
+ if (running >= THUMB_JOBS) await new Promise<void>((go) => waiting.push(go))
65
+ running++
66
+ try {
67
+ return await work()
68
+ } finally {
69
+ running--
70
+ waiting.shift()?.()
71
+ }
72
+ }
73
+
74
+ export function thumbFile(dir: string, id: string, name: string): Promise<string | null> {
75
+ const src = imageFile(dir, id, name)
76
+ if (!src || !/\.(png|jpe?g|webp)$/.test(name)) return Promise.resolve(null)
77
+ const out = join(dir, '.thumbs', id, `${name}.webp`)
78
+ if (existsSync(out) && statSync(out).mtimeMs >= statSync(src).mtimeMs) return Promise.resolve(out)
79
+ const pending = making.get(out)
80
+ if (pending) return pending
81
+ const job = slot(async () => {
82
+ mkdirSync(join(dir, '.thumbs', id), { recursive: true })
83
+ const tmp = `${out}.${process.pid}.tmp`
84
+ try {
85
+ const { default: sharp } = await import('sharp')
86
+ await sharp(src).resize({ width: THUMB_W, withoutEnlargement: true }).webp({ quality: 75 }).toFile(tmp)
87
+ } catch (e) {
88
+ console.error(`FAIL: review thumbnail of ${id}/${name}: ${(e as Error).message}`)
89
+ return null
90
+ }
91
+ renameSync(tmp, out)
92
+ return out
93
+ }).finally(() => making.delete(out))
94
+ making.set(out, job)
95
+ return job
96
+ }
97
+
98
+ let listing: { dir: string; tag: string; body: string; zipped: Uint8Array<ArrayBuffer> | null } | null = null
99
+
100
+ async function reviewRoute(req: Request, path: string, dir: string, unit: ReviewUnit | null): Promise<Response | null> {
101
+ if (path === '/api/review' && req.method === 'GET') {
102
+ // the page polls this list: it is read from disk and sent again only when a file of it changed
103
+ const tag = `"${storeStamp(dir)}"`
104
+ if (req.headers.get('If-None-Match') === tag) return new Response(null, { status: 304, headers: { ETag: tag } })
105
+ if (listing?.dir !== dir || listing.tag !== tag) listing = { dir, tag, body: JSON.stringify(readAllCached(dir)), zipped: null }
106
+ const headers = { 'Content-Type': 'application/json', ETag: tag, 'Cache-Control': 'no-cache', Vary: 'Accept-Encoding' }
107
+ if (!/\bgzip\b/.test(req.headers.get('Accept-Encoding') ?? '')) return new Response(listing.body, { headers })
108
+ listing.zipped ??= Bun.gzipSync(new TextEncoder().encode(listing.body))
109
+ return new Response(listing.zipped, { headers: { ...headers, 'Content-Encoding': 'gzip' } })
110
+ }
111
+ if (path === '/api/review/watchers' && req.method === 'GET') return Response.json(liveWatchers(dir), { headers: NO_STORE })
112
+ // the unit's words, the dispatcher's holder: what the page draws the list's filters, labels and board with
113
+ if (path === '/api/review/spec' && req.method === 'GET')
114
+ return Response.json({ ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })) }, { headers: NO_STORE })
115
+ if ((path === '/api/review/raise' || path === '/api/review/rate') && req.method === 'POST') {
116
+ let img: ReturnType<typeof dataUrlFile> = null
117
+ try {
118
+ const b = (await req.json()) as Record<string, unknown>
119
+ if (!obj(b.subject) || typeof b.subject.kind !== 'string') throw new Error('subject: { kind, … }')
120
+ if (typeof b.from !== 'string' || b.from === '') throw new Error('from: the tool he raises it from')
121
+ if (typeof b.title !== 'string' || b.title.trim() === '') throw new Error('title: a text')
122
+ const str = (k: string): string | undefined => (typeof b[k] === 'string' && b[k] !== '' ? (b[k] as string) : undefined)
123
+ const subject = b.subject as Subject
124
+ if (path === '/api/review/rate') {
125
+ if (!obj(b.rate)) throw new Error('rate: { stars?, mark?, part? }')
126
+ const topic = str('topic')
127
+ const text = str('text')
128
+ const m = await rateSubject(dir, { subject, from: b.from, title: b.title, rate: b.rate, ...(topic ? { topic } : {}), ...(text ? { text } : {}) })
129
+ return Response.json(m, { headers: NO_STORE })
130
+ }
131
+ img = dataUrlFile(b.image)
132
+ const lane = str('lane')
133
+ const kind = b.kind === 'bug' || b.kind === 'demande' ? b.kind : undefined
134
+ const topic = str('topic')
135
+ const what = str('what')
136
+ const commit = str('commit')
137
+ const x = await raiseItem(dir, {
138
+ subject,
139
+ origin: { by: 'owner', from: b.from, ...(kind ? { kind } : {}), ...(lane ? { lane } : {}) },
140
+ title: b.title,
141
+ ...(typeof b.text === 'string' ? { text: b.text } : {}),
142
+ ...(Array.isArray(b.rects) && b.rects.length > 0 ? { rects: b.rects as Rect[] } : {}),
143
+ ...(topic ? { topic } : {}),
144
+ ...(what ? { what } : {}),
145
+ ...(commit ? { commit } : {}),
146
+ ...(img ? { image: img.file } : {}),
147
+ })
148
+ return Response.json(x, { headers: NO_STORE })
149
+ } catch (e) {
150
+ return bad(e)
151
+ } finally {
152
+ img?.done()
153
+ }
154
+ }
155
+ const t = /^\/api\/review\/([^/]+)\/thread\/([^/]+)$/.exec(path)
156
+ if (t && req.method === 'GET') {
157
+ const f = threadImage(dir, t[1] ?? '', t[2] ?? '')
158
+ return f ? new Response(Bun.file(f), { headers: { 'Content-Type': TYPES[extname(f)] ?? 'application/octet-stream', ...KEEP } }) : bad('no such image', 404)
159
+ }
160
+ const th = /^\/api\/review\/([^/]+)\/thumb\/([^/]+)$/.exec(path)
161
+ if (th && req.method === 'GET') {
162
+ const f = await thumbFile(dir, th[1] ?? '', th[2] ?? '')
163
+ return f ? new Response(Bun.file(f), { headers: { 'Content-Type': 'image/webp', ...KEEP } }) : bad('no such image', 404)
164
+ }
165
+ const m = /^\/api\/review\/([^/]+)\/([^/]+)$/.exec(path)
166
+ if (!m) return null
167
+ const [, id = '', name = ''] = m
168
+ if (req.method === 'POST') {
169
+ try {
170
+ if (name === 'feedback') {
171
+ const r = writeVerdict(dir, id, await req.json())
172
+ return r.ok ? Response.json(r.message, { headers: NO_STORE }) : Response.json({ error: r.errors.join('\n'), errors: r.errors }, { status: 400, headers: NO_STORE })
173
+ }
174
+ if (name === 'reply') return Response.json(addMessage(dir, id, 'owner', ((await req.json()) as { text?: unknown }).text), { headers: NO_STORE })
175
+ if (name === 'read') return Response.json({ read: markRead(dir, id, 'owner') }, { headers: NO_STORE })
176
+ // the card's folder, or one of its files, shown in the Finder: only names the store's own checks accept
177
+ if (name === 'reveal') {
178
+ const b = (await req.json().catch(() => ({}))) as { file?: unknown }
179
+ const target = typeof b.file === 'string' ? imageFile(dir, id, b.file) : readItem(dir, id) ? join(dir, id) : null
180
+ if (!target) return bad(`no ${typeof b.file === 'string' ? `file ${id}/${b.file}` : `item ${id}`}`, 404)
181
+ const r = Bun.spawnSync(['open', ...(typeof b.file === 'string' ? ['-R'] : []), target], { stdout: 'ignore', stderr: 'pipe' })
182
+ return r.exitCode === 0 ? Response.json({ ok: true }, { headers: NO_STORE }) : bad(`open: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`, 500)
183
+ }
184
+ } catch (e) {
185
+ return bad(e)
186
+ }
187
+ return bad(`POST ${path}?`, 404)
188
+ }
189
+ if (req.method !== 'GET') return null
190
+ // a kind's data view of one item, computed here: the page never runs the repo's code
191
+ if (name === 'view') {
192
+ try {
193
+ const x = readItem(dir, id)
194
+ return x ? Response.json(viewOfItem(unit, x), { headers: NO_STORE }) : bad(`no item ${id}`, 404)
195
+ } catch (e) {
196
+ return bad(e)
197
+ }
198
+ }
199
+ if (name === 'anim') {
200
+ const q = new URL(req.url).searchParams
201
+ const mode = q.get('mode') ?? 'fade'
202
+ const format = q.get('format') ?? 'mp4'
203
+ const labels = q.get('labels') ?? 'en'
204
+ if (!isAnimMode(mode) || !isAnimFormat(format) || !isAnimLabels(labels)) return bad(`mode ${mode}, format ${format} or labels ${labels}: unknown`)
205
+ try {
206
+ const font = unit?.anim?.font ? join(dir, '..', '..', unit.anim.font) : undefined
207
+ const a = await pairAnimation(dir, id, { view: Number(q.get('view') ?? 0) || 0, mode, format, labels, ...(font ? { font } : {}) })
208
+ return new Response(Bun.file(a.file), { headers: { 'Content-Type': format === 'gif' ? 'image/gif' : 'video/mp4', 'Content-Disposition': `attachment; filename="${a.name}"`, ...NO_STORE } })
209
+ } catch (e) {
210
+ console.error(`FAIL: review animation of ${id}: ${(e as Error).message}`)
211
+ return bad(e)
212
+ }
213
+ }
214
+ const f = imageFile(dir, id, name)
215
+ return f ? ranged(req, f) : bad(`no file ${id}/${name}`, 404)
216
+ }
217
+
218
+ /** the review and trailer routes of the repo at `root`; null for any other path */
219
+ export async function reviewRoutes(root: string, req: Request, path: string): Promise<Response | null> {
220
+ if (!path.startsWith('/api/review') && !path.startsWith('/api/trailer')) return null
221
+ let opened: { dir: string; unit: ReviewUnit | null }
222
+ try {
223
+ opened = await project(root)
224
+ } catch (e) {
225
+ return bad(`the repo's review unit does not load: ${(e as Error).message}`, 500)
226
+ }
227
+ return (await reviewRoute(req, path, opened.dir, opened.unit)) ?? (await trailerRoutes(req, path, root, opened.dir))
228
+ }