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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- 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
|
+
}
|