rvlib-shipkit 2.1.1 → 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 +50 -0
- package/package.json +2 -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/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- 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 +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- 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/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- 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 +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- 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 +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
// the one feedback store's model (wiki build/shipkit/review.md): every record the owner, a player or an agent raises
|
|
2
|
+
// is an ITEM, a head (`item.json`: a typed subject, who raised it, the views) and an append only thread of messages
|
|
3
|
+
// (verdicts, replies, ratings, state changes, read marks). The state is derived from the thread, never stored, so the
|
|
4
|
+
// hub and every CLI write at once with no lost update. Pure and browser safe: the store, the routes and the hub page
|
|
5
|
+
// share it. The on disk shape is pkm-multi's (scripts/review/store.ts) verbatim, so either store opens in the other.
|
|
6
|
+
|
|
7
|
+
/** WHAT is judged: `shot` (a plain image or clip), `screen`, `frame` (a frame of a trailer cut), or a kind a repo names
|
|
8
|
+
* for itself (pkm-multi's place, creature, song...), read as is */
|
|
9
|
+
export interface Subject {
|
|
10
|
+
kind: string
|
|
11
|
+
[k: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** a frame of a trailer cut: THE ANCHOR is its shot's name and the seconds into it, so it survives a new render */
|
|
15
|
+
export interface FrameSubject extends Subject {
|
|
16
|
+
kind: 'frame'
|
|
17
|
+
trailer: string
|
|
18
|
+
cut: string
|
|
19
|
+
/** the render on screen when it was written, its mtime in ms */
|
|
20
|
+
version?: string
|
|
21
|
+
time: number
|
|
22
|
+
frame: number
|
|
23
|
+
shot: string
|
|
24
|
+
shotTime: number
|
|
25
|
+
section: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isFrame = (s: Subject): s is FrameSubject => s.kind === 'frame'
|
|
29
|
+
|
|
30
|
+
/** what a repo's `review({...})` unit says of one subject kind that the pure model reads (review/unit.ts holds the rest):
|
|
31
|
+
* its words, and on the server its key, its near rule and its problems. The hub gets the words alone */
|
|
32
|
+
export interface SpecKind {
|
|
33
|
+
label: string
|
|
34
|
+
icon?: string
|
|
35
|
+
/** what names one subject of this kind exactly, the kind left out; null names nothing */
|
|
36
|
+
key?(s: Subject): string | null
|
|
37
|
+
/** two subjects of this kind, in one topic, close enough to be one card */
|
|
38
|
+
near?(a: Subject, b: Subject): boolean
|
|
39
|
+
/** what is wrong with a subject of this kind; empty: nothing */
|
|
40
|
+
problems?(s: Subject): string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** an area raised items are routed to, and the pages its worker reads first */
|
|
44
|
+
export interface SpecTopic {
|
|
45
|
+
label: string
|
|
46
|
+
covers?: string
|
|
47
|
+
read?: readonly string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** a repo's review parts as the model reads them; NO_SPEC for a repo with no unit */
|
|
51
|
+
export interface Spec {
|
|
52
|
+
kinds: Record<string, SpecKind>
|
|
53
|
+
topics: Record<string, SpecTopic>
|
|
54
|
+
/** one session holds the dispatcher role and routes what is raised */
|
|
55
|
+
dispatcher: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** the kinds every repo has, whatever its unit declares */
|
|
59
|
+
export const BUILTIN_KINDS: Record<string, SpecKind> = { shot: { label: 'images' }, screen: { label: 'screens' }, frame: { label: 'trailer frames' } }
|
|
60
|
+
|
|
61
|
+
export const NO_SPEC: Spec = { kinds: {}, topics: {}, dispatcher: false }
|
|
62
|
+
|
|
63
|
+
/** the words the list's subject filter shows; a kind no unit names shows its own name */
|
|
64
|
+
export const subjectLabel = (kind: string, spec: Spec = NO_SPEC): string => (spec.kinds[kind] ?? BUILTIN_KINDS[kind])?.label ?? kind
|
|
65
|
+
|
|
66
|
+
/** a topic's words, its id when no unit names it */
|
|
67
|
+
export const topicLabel = (topic: string, spec: Spec = NO_SPEC): string => spec.topics[topic]?.label ?? topic
|
|
68
|
+
|
|
69
|
+
/** WHO raised it: an agent's card goes back to its lane on a rejection */
|
|
70
|
+
export type Origin =
|
|
71
|
+
| { by: 'agent'; lane?: string; session?: string }
|
|
72
|
+
| { by: 'owner'; from: string; kind?: string; lane?: string }
|
|
73
|
+
| { by: 'player'; player: string; server: string; serverId: number; client: string; context: Record<string, unknown> }
|
|
74
|
+
| { by: 'auto'; rule: string; key: string; lane: string }
|
|
75
|
+
|
|
76
|
+
/** what names one subject exactly, null when it names nothing (a plain shot, a screen): two items of different keys
|
|
77
|
+
* are never one subject. A kind the unit declares names it by its own `key`, or names nothing without one; a kind no
|
|
78
|
+
* unit declares by its first of species, key, slug, id */
|
|
79
|
+
export function subjectKey(s: Subject, spec: Spec = NO_SPEC): string | null {
|
|
80
|
+
if (isFrame(s)) return `frame:${s.trailer}/${s.cut}/${s.shot}`
|
|
81
|
+
const kind = spec.kinds[s.kind]
|
|
82
|
+
if (kind) {
|
|
83
|
+
const k = kind.key?.(s)
|
|
84
|
+
return k ? `${s.kind}:${k}` : null
|
|
85
|
+
}
|
|
86
|
+
for (const k of ['species', 'key', 'slug', 'id'] as const) if (typeof s[k] === 'string' && s[k] !== '') return `${s.kind}:${s[k] as string}`
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** an image, a clip, a sound, or a pair: two images, two clips or two sounds */
|
|
91
|
+
export type ReviewFiles = { image: string } | { video: string } | { audio: string } | { before: string; after: string }
|
|
92
|
+
|
|
93
|
+
/** a clip view's first frame (a png of the folder), its length in seconds, its frames a second */
|
|
94
|
+
export interface ReviewClip {
|
|
95
|
+
poster: string
|
|
96
|
+
duration: number
|
|
97
|
+
fps: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** a sound view: its length in seconds, and its waveform, a png of the folder (the after's, and the before's on a pair);
|
|
101
|
+
* the view's `size` is the waveform's */
|
|
102
|
+
export interface ReviewAudio {
|
|
103
|
+
duration: number
|
|
104
|
+
wave: string
|
|
105
|
+
waveBefore?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** a ring a repo drew over its own shot (pkm-multi's world tiles): its center normalized 0..1, its radius as a share of
|
|
109
|
+
* the image width; `side` only on a pair */
|
|
110
|
+
export interface ReviewMark {
|
|
111
|
+
tile: [number, number]
|
|
112
|
+
edge?: 'n' | 'e' | 's' | 'w'
|
|
113
|
+
x: number
|
|
114
|
+
y: number
|
|
115
|
+
r: number
|
|
116
|
+
side?: Side
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** one view of a card's subject: an image or a pair, its size, its marks; the first view's label may be empty */
|
|
120
|
+
export interface MediaView {
|
|
121
|
+
label: string
|
|
122
|
+
files: ReviewFiles
|
|
123
|
+
size: { w: number; h: number }
|
|
124
|
+
clip?: ReviewClip
|
|
125
|
+
audio?: ReviewAudio
|
|
126
|
+
marks?: ReviewMark[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** one image of a gallery view, a file of the folder (`v<n>-g<k>.<ext>`) */
|
|
130
|
+
export interface GalleryTile {
|
|
131
|
+
file: string
|
|
132
|
+
label: string
|
|
133
|
+
size: { w: number; h: number }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** a view that is a list of small images, shown as a wrapping grid; never the first view */
|
|
137
|
+
export interface GalleryView {
|
|
138
|
+
label: string
|
|
139
|
+
gallery: GalleryTile[]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type ReviewView = MediaView | GalleryView
|
|
143
|
+
|
|
144
|
+
export const isGallery = (v: ReviewView): v is GalleryView => 'gallery' in v
|
|
145
|
+
|
|
146
|
+
/** `item.json`: the head, written at creation, changed only by `add-view` */
|
|
147
|
+
export interface Item {
|
|
148
|
+
id: string
|
|
149
|
+
/** ISO time */
|
|
150
|
+
created: string
|
|
151
|
+
subject: Subject
|
|
152
|
+
origin: Origin
|
|
153
|
+
title: string
|
|
154
|
+
/** an agent card's markdown: what changed and why */
|
|
155
|
+
what?: string
|
|
156
|
+
topic?: string
|
|
157
|
+
commit?: string
|
|
158
|
+
/** the first is a media view, never a gallery */
|
|
159
|
+
views: ReviewView[]
|
|
160
|
+
/** an earlier card this one is a new version of; never a raised item */
|
|
161
|
+
supersedes?: string
|
|
162
|
+
/** the raised items (a report, his note) this card answers: one thread per bug */
|
|
163
|
+
answers?: string[]
|
|
164
|
+
/** what this new version keeps of the approved look it follows */
|
|
165
|
+
keeps?: string
|
|
166
|
+
/** why this card passed --new next to an active card */
|
|
167
|
+
why?: string
|
|
168
|
+
light?: 'review'
|
|
169
|
+
/** a draft card of a repo that shoots live: no verdict until it is ready */
|
|
170
|
+
wip?: { live: string; frame: number }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** a view's name as the page and the CLI show it: its label, or « view n » */
|
|
174
|
+
export const viewName = (v: ReviewView | undefined, i: number): string => v?.label || `view ${i + 1}`
|
|
175
|
+
|
|
176
|
+
/** the first view, media when there is one; a note with no image has none */
|
|
177
|
+
export const firstView = (i: Item): MediaView | undefined => i.views[0] as MediaView | undefined
|
|
178
|
+
|
|
179
|
+
/** the file that stands for a view in a list: a clip's first frame, the after of a pair, the image */
|
|
180
|
+
export function thumbFileOf(v: ReviewView): string {
|
|
181
|
+
if (isGallery(v)) return v.gallery[0]?.file ?? ''
|
|
182
|
+
if (v.clip) return v.clip.poster
|
|
183
|
+
if (v.audio) return v.audio.wave
|
|
184
|
+
const f = v.files
|
|
185
|
+
return 'image' in f ? f.image : 'video' in f ? f.video : 'audio' in f ? f.audio : f.after
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** a rectangle drawn at clip time `t` is on the frame shown at `time` */
|
|
189
|
+
export const onFrame = (t: number, time: number, fps: number): boolean => Math.floor(t * fps + 1e-6) === Math.floor(time * fps + 1e-6)
|
|
190
|
+
|
|
191
|
+
export type Side = 'before' | 'after'
|
|
192
|
+
|
|
193
|
+
/** a rectangle on a view, normalized 0..1; w = h = 0 is a pin. `view` the index of its view, absent on the first;
|
|
194
|
+
* `side` only on a pair; `t` only on a clip */
|
|
195
|
+
export interface Rect {
|
|
196
|
+
x: number
|
|
197
|
+
y: number
|
|
198
|
+
w: number
|
|
199
|
+
h: number
|
|
200
|
+
comment: string
|
|
201
|
+
view?: number
|
|
202
|
+
side?: Side
|
|
203
|
+
/** the clip time it was drawn at, in seconds */
|
|
204
|
+
t?: number
|
|
205
|
+
world?: { x0: number; y0: number; x1: number; y1: number }
|
|
206
|
+
nodes?: string[]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `approved-with-fix` counts as approved: its comment and rectangles are a small follow up; `archived` is set aside
|
|
210
|
+
* with no verdict, so it asks nothing of the agent */
|
|
211
|
+
export const STATUSES = ['approved', 'approved-with-fix', 'rejected', 'archived'] as const
|
|
212
|
+
export type ReviewStatus = (typeof STATUSES)[number]
|
|
213
|
+
|
|
214
|
+
export const isApproved = (s: ReviewStatus): boolean => s === 'approved' || s === 'approved-with-fix'
|
|
215
|
+
|
|
216
|
+
/** the owner's reaction to one view, or to one tile of a gallery view; one per view or tile */
|
|
217
|
+
export interface ReviewReaction {
|
|
218
|
+
view: number
|
|
219
|
+
tile?: number
|
|
220
|
+
status?: ReviewStatus
|
|
221
|
+
comment: string
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** a state change, or a read mark (`seen`, with the ids it read in `of`) */
|
|
225
|
+
export const OPS = ['dispatch', 'ask', 'answer', 'reassign', 'claim', 'release', 'blocked', 'fixed', 'done', 'wontfix', 'drop', 'withdraw', 'reopen', 'ready', 'seen'] as const
|
|
226
|
+
export type Op = (typeof OPS)[number]
|
|
227
|
+
|
|
228
|
+
/** one act on an item, one file of `thread/`, never rewritten */
|
|
229
|
+
export interface Message {
|
|
230
|
+
id: string
|
|
231
|
+
/** ISO time */
|
|
232
|
+
at: string
|
|
233
|
+
/** `owner`, `player`, or an agent: its lane name, `agent` when unnamed */
|
|
234
|
+
who: string
|
|
235
|
+
text?: string
|
|
236
|
+
/** a file of the folder's `thread/` beside it */
|
|
237
|
+
image?: string
|
|
238
|
+
rects?: Rect[]
|
|
239
|
+
reactions?: ReviewReaction[]
|
|
240
|
+
verdict?: ReviewStatus
|
|
241
|
+
/** a repo's own mark on a subject (stars, a song's mark): kept as written */
|
|
242
|
+
rate?: { stars?: number; mark?: string; part?: string }
|
|
243
|
+
op?: Op
|
|
244
|
+
/** the op's reason or answer */
|
|
245
|
+
note?: string
|
|
246
|
+
/** `dispatch`: where it goes */
|
|
247
|
+
topic?: string
|
|
248
|
+
/** `seen`: the messages it read */
|
|
249
|
+
of?: string[]
|
|
250
|
+
fix?: { commit: string }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** what the store reads of one folder, what the list sends */
|
|
254
|
+
export interface ReviewItem {
|
|
255
|
+
item: Item
|
|
256
|
+
/** oldest first */
|
|
257
|
+
thread: Message[]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** the two parties of a thread: the owner and the players on one side, the agents on the other */
|
|
261
|
+
export type Party = 'agent' | 'owner'
|
|
262
|
+
export const partyOf = (who: string): Party => (who === 'owner' || who === 'player' ? 'owner' : 'agent')
|
|
263
|
+
|
|
264
|
+
/** a message one party says to the other: a reply, a verdict, a rating, a question and its answer; never another op */
|
|
265
|
+
const isTalk = (m: Message): boolean => m.op === undefined || ((m.op === 'ask' || m.op === 'answer') && m.text !== undefined)
|
|
266
|
+
|
|
267
|
+
/** a reply: talk with words that is no verdict and no rating (a message moving a pin alone is no reply) */
|
|
268
|
+
export const isReply = (m: Message): boolean => isTalk(m) && m.verdict === undefined && m.rate === undefined && m.text !== undefined
|
|
269
|
+
|
|
270
|
+
export const MESSAGE_RE = /^\d{8}-\d{6}-\d{3}-(agent|owner|player)(-[a-z0-9]+)*$/
|
|
271
|
+
export const MESSAGE_FILE_RE = /^\d{8}-\d{6}-\d{3}-(agent|owner|player)(-[a-z0-9]+)*\.(png|jpe?g|webp)$/
|
|
272
|
+
const MAX_TEXT = 4000
|
|
273
|
+
|
|
274
|
+
/** an agent's name: its lane (`L135`, `REVIEW`) */
|
|
275
|
+
export const AGENT_RE = /^[A-Za-z][A-Za-z0-9-]{0,23}$/
|
|
276
|
+
|
|
277
|
+
const two = (n: number): string => String(n).padStart(2, '0')
|
|
278
|
+
const stampOf = (at: Date): string => `${at.getFullYear()}${two(at.getMonth() + 1)}${two(at.getDate())}-${two(at.getHours())}${two(at.getMinutes())}${two(at.getSeconds())}`
|
|
279
|
+
|
|
280
|
+
/** `YYYYMMDD-HHMMSS-mmm-<owner|player|agent>`, local time: the names sort in time */
|
|
281
|
+
export function messageIdOf(at: Date, who: string): string {
|
|
282
|
+
const role = who === 'owner' || who === 'player' ? who : 'agent'
|
|
283
|
+
return `${stampOf(at)}-${String(at.getMilliseconds()).padStart(3, '0')}-${role}`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** a message's text as sent, trimmed; an error is the reason it is refused */
|
|
287
|
+
export function textOf(v: unknown): { text: string } | { error: string } {
|
|
288
|
+
if (typeof v !== 'string' || v.trim() === '') return { error: 'text: required' }
|
|
289
|
+
if (v.length > MAX_TEXT) return { error: `text: at most ${MAX_TEXT} characters` }
|
|
290
|
+
return { text: v.trim() }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** every message id a party marked read */
|
|
294
|
+
const readBy = (x: ReviewItem, party: Party): Set<string> =>
|
|
295
|
+
new Set(x.thread.flatMap((m) => (m.op === 'seen' && partyOf(m.who) === party ? (m.of ?? []) : [])))
|
|
296
|
+
|
|
297
|
+
/** the talk of the other party that `party` has not read: replies, verdicts, ratings */
|
|
298
|
+
const unreadTalk = (x: ReviewItem, party: Party): Message[] => {
|
|
299
|
+
const read = readBy(x, party)
|
|
300
|
+
return x.thread.filter((m) => isTalk(m) && partyOf(m.who) !== party && !read.has(m.id))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** the replies of the other party `party` has not read; a verdict is no reply (verdictOf says whether it was seen) */
|
|
304
|
+
export const unreadBy = (x: ReviewItem, party: Party): Message[] => unreadTalk(x, party).filter(isReply)
|
|
305
|
+
|
|
306
|
+
/** what a `seen` of `party` reads on `x`: every message of the other party it has not read yet */
|
|
307
|
+
export const toAcknowledge = (x: ReviewItem, party: Party): string[] => unreadTalk(x, party).map((m) => m.id)
|
|
308
|
+
|
|
309
|
+
/** the owner's verdict as the card shows it: the latest one since the last reopen; `seen`: an agent read it */
|
|
310
|
+
export interface Verdict {
|
|
311
|
+
id: string
|
|
312
|
+
status: ReviewStatus
|
|
313
|
+
comment: string
|
|
314
|
+
rects: Rect[]
|
|
315
|
+
reactions?: ReviewReaction[]
|
|
316
|
+
at: string
|
|
317
|
+
seen: boolean
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function verdictOf(x: ReviewItem): Verdict | null {
|
|
321
|
+
let m: Message | undefined
|
|
322
|
+
let text: string | undefined
|
|
323
|
+
let rects: Rect[] | undefined
|
|
324
|
+
for (const n of x.thread) {
|
|
325
|
+
if (n.op === 'reopen') [m, text, rects] = [undefined, undefined, undefined]
|
|
326
|
+
else if (n.verdict !== undefined) {
|
|
327
|
+
if (m?.verdict !== n.verdict) [text, rects] = [undefined, undefined]
|
|
328
|
+
m = n
|
|
329
|
+
if (n.text !== undefined) text = n.text
|
|
330
|
+
if (n.rects !== undefined) rects = n.rects
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!m?.verdict) return null
|
|
334
|
+
return {
|
|
335
|
+
id: m.id,
|
|
336
|
+
status: m.verdict,
|
|
337
|
+
// a bare repeat of the same verdict keeps the words and rectangles said with it before
|
|
338
|
+
comment: text ?? '',
|
|
339
|
+
rects: rects ?? [],
|
|
340
|
+
...(m.reactions && m.reactions.length > 0 ? { reactions: m.reactions } : {}),
|
|
341
|
+
at: m.at,
|
|
342
|
+
seen: readBy(x, 'agent').has(m.id),
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** why the agent took the card back unanswered; undefined when it did not */
|
|
347
|
+
export const withdrawnOf = (x: ReviewItem): string | undefined => x.thread.filter((m) => m.op === 'withdraw').at(-1)?.note
|
|
348
|
+
|
|
349
|
+
/** what the list says of an item: an agent's card by the owner's verdict, what he or a player raised by its state */
|
|
350
|
+
export type ShownStatus = 'pending' | 'wip' | ReviewStatus | 'superseded' | 'withdrawn' | 'raised' | 'fixed' | 'wontfix' | 'rated'
|
|
351
|
+
|
|
352
|
+
export const STATUS_LABEL: Record<ShownStatus, string> = {
|
|
353
|
+
pending: 'waiting',
|
|
354
|
+
wip: 'in progress',
|
|
355
|
+
approved: 'approved',
|
|
356
|
+
'approved-with-fix': 'ok with a fix',
|
|
357
|
+
rejected: 'rejected',
|
|
358
|
+
archived: 'archived, later',
|
|
359
|
+
superseded: 'replaced',
|
|
360
|
+
withdrawn: 'withdrawn',
|
|
361
|
+
raised: 'open',
|
|
362
|
+
fixed: 'done',
|
|
363
|
+
wontfix: 'not done',
|
|
364
|
+
rated: 'rated',
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** the lifecycle (wiki build/shipkit/review.md, The lifecycle) */
|
|
368
|
+
export type State = 'draft' | 'new' | 'question' | 'open' | 'claimed' | 'blocked' | 'asks' | 'closed'
|
|
369
|
+
type ClosedWhy = ReviewStatus | 'fixed' | 'wontfix' | 'dropped' | 'superseded' | 'withdrawn' | 'rated'
|
|
370
|
+
|
|
371
|
+
export interface ItemState {
|
|
372
|
+
state: State
|
|
373
|
+
why?: ClosedWhy
|
|
374
|
+
topic?: string
|
|
375
|
+
/** who holds a claim */
|
|
376
|
+
worker?: string
|
|
377
|
+
/** the time of the `claim` that holds it; none on the claim the owner's verdict gives the card's lane until it is read */
|
|
378
|
+
since?: string
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** whose move it is: the owner's (asks, question, blocked), the agents' (new, open, claimed), nobody's (draft, closed) */
|
|
382
|
+
export type Move = 'owner' | 'agents' | 'none'
|
|
383
|
+
export const moveOf = (s: State): Move =>
|
|
384
|
+
s === 'asks' || s === 'question' || s === 'blocked' ? 'owner' : s === 'new' || s === 'open' || s === 'claimed' ? 'agents' : 'none'
|
|
385
|
+
|
|
386
|
+
export const MOVE_LABEL: Record<Move, string> = { owner: 'mine', agents: 'in work', none: 'done' }
|
|
387
|
+
|
|
388
|
+
/** where an item starts: an agent's card asks for the owner's verdict (a draft waits); what he raises from a tool that named
|
|
389
|
+
* its topic (the trailer view: `film`) is open, else it waits on a dispatcher; an auto rule's item is open */
|
|
390
|
+
const startOf = (i: Item): ItemState =>
|
|
391
|
+
i.origin.by === 'agent'
|
|
392
|
+
? { state: i.wip ? 'draft' : 'asks' }
|
|
393
|
+
: i.topic || i.origin.by === 'auto'
|
|
394
|
+
? { state: 'open', ...(i.topic ? { topic: i.topic } : {}) }
|
|
395
|
+
: { state: 'new' }
|
|
396
|
+
|
|
397
|
+
/** the state of an item, folded from its thread. A verdict wins over a withdrawal, as on the card; `superseded`: a
|
|
398
|
+
* later version follows it. « fix » and « no » give the card back to its author until an agent reads them */
|
|
399
|
+
export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
|
|
400
|
+
const i = x.item
|
|
401
|
+
const lane = i.origin.by === 'agent' ? i.origin.lane : undefined
|
|
402
|
+
let s: ItemState = startOf(i)
|
|
403
|
+
// what his latest verdict left: a released or superseded rework of an agent's card goes back to it
|
|
404
|
+
let given: ItemState = s
|
|
405
|
+
const read = readBy(x, 'agent')
|
|
406
|
+
for (const m of x.thread) {
|
|
407
|
+
const keep = s.topic ? { topic: s.topic } : {}
|
|
408
|
+
if (m.verdict !== undefined) {
|
|
409
|
+
const v = m.verdict
|
|
410
|
+
if (v === 'approved' || v === 'archived' || read.has(m.id)) s = { ...keep, state: 'closed', why: v }
|
|
411
|
+
else s = { ...keep, state: 'claimed', ...(lane ? { worker: lane } : {}) }
|
|
412
|
+
given = s
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
if (m.rate !== undefined) {
|
|
416
|
+
s = { ...keep, state: 'closed', why: 'rated' }
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
// the owner's words on a question answer it: it goes back to the dispatcher
|
|
420
|
+
if (m.op === undefined && partyOf(m.who) === 'owner' && m.text !== undefined && s.state === 'question') {
|
|
421
|
+
s = { ...keep, state: 'new' }
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
switch (m.op) {
|
|
425
|
+
case 'dispatch':
|
|
426
|
+
case 'reassign':
|
|
427
|
+
s = { state: 'open', ...(m.topic ? { topic: m.topic } : keep) }
|
|
428
|
+
break
|
|
429
|
+
case 'ask':
|
|
430
|
+
s = { ...keep, state: 'question' }
|
|
431
|
+
break
|
|
432
|
+
case 'answer':
|
|
433
|
+
s = { ...keep, state: 'new' }
|
|
434
|
+
break
|
|
435
|
+
case 'claim':
|
|
436
|
+
s = { ...keep, state: 'claimed', worker: m.who, since: m.at }
|
|
437
|
+
break
|
|
438
|
+
case 'release':
|
|
439
|
+
s = i.origin.by === 'agent' ? given : { ...keep, state: 'open' }
|
|
440
|
+
break
|
|
441
|
+
case 'blocked':
|
|
442
|
+
s = { ...keep, state: 'blocked' }
|
|
443
|
+
break
|
|
444
|
+
case 'fixed':
|
|
445
|
+
s = { ...keep, state: 'closed', why: 'fixed' }
|
|
446
|
+
break
|
|
447
|
+
case 'done':
|
|
448
|
+
s = { ...keep, state: 'closed', why: verdictOf(x)?.status === 'approved-with-fix' ? 'approved-with-fix' : 'fixed' }
|
|
449
|
+
break
|
|
450
|
+
case 'wontfix':
|
|
451
|
+
s = { ...keep, state: 'closed', why: 'wontfix' }
|
|
452
|
+
break
|
|
453
|
+
case 'drop':
|
|
454
|
+
s = { ...keep, state: 'closed', why: 'dropped' }
|
|
455
|
+
break
|
|
456
|
+
case 'reopen':
|
|
457
|
+
s = i.origin.by === 'agent' ? { ...keep, state: 'asks' } : { ...keep, state: s.topic || i.origin.by === 'auto' ? 'open' : 'new' }
|
|
458
|
+
break
|
|
459
|
+
default:
|
|
460
|
+
break
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// the card that redoes it landed: the rework is over
|
|
464
|
+
if (superseded && i.origin.by === 'agent' && s.since !== undefined) return given
|
|
465
|
+
if (verdictOf(x) === null) {
|
|
466
|
+
if (withdrawnOf(x) !== undefined) return { state: 'closed', why: 'withdrawn' }
|
|
467
|
+
if (superseded && s.state !== 'closed') return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: 'superseded' }
|
|
468
|
+
}
|
|
469
|
+
return s
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** the states each op leaves from */
|
|
473
|
+
const FROM: Record<Exclude<Op, 'seen'>, readonly State[]> = {
|
|
474
|
+
dispatch: ['new', 'blocked'],
|
|
475
|
+
ask: ['new', 'blocked'],
|
|
476
|
+
answer: ['question'],
|
|
477
|
+
reassign: ['open', 'claimed'],
|
|
478
|
+
drop: ['new', 'question', 'open', 'blocked'],
|
|
479
|
+
claim: ['open', 'claimed', 'closed'],
|
|
480
|
+
release: ['claimed'],
|
|
481
|
+
blocked: ['claimed'],
|
|
482
|
+
fixed: ['open', 'claimed'],
|
|
483
|
+
done: ['claimed'],
|
|
484
|
+
wontfix: ['open', 'claimed'],
|
|
485
|
+
withdraw: ['draft', 'asks'],
|
|
486
|
+
reopen: ['closed', 'claimed'],
|
|
487
|
+
ready: ['draft'],
|
|
488
|
+
}
|
|
489
|
+
const DISPATCHER_OPS = new Set<Op>(['dispatch', 'ask', 'drop', 'reassign'])
|
|
490
|
+
const WORKER_OPS = new Set<Op>(['release', 'blocked', 'fixed', 'done', 'wontfix'])
|
|
491
|
+
|
|
492
|
+
/** null when `who` may do `op` on an item in state `s`, else why not. The dispatcher (null: nobody holds the role)
|
|
493
|
+
* routes, a worker acts on its own claim, the owner answers and may act over a claim. `routed` false: the repo has no
|
|
494
|
+
* dispatcher, so a worker takes a new item as it is */
|
|
495
|
+
export function allowed(s: ItemState, op: Op, who: string, dispatcher: string | null, routed = true): string | null {
|
|
496
|
+
if (op === 'seen') return null
|
|
497
|
+
if (op === 'answer' && who !== 'owner') return 'only the owner answers a question'
|
|
498
|
+
if (DISPATCHER_OPS.has(op)) {
|
|
499
|
+
if (!routed) return 'this repo routes nothing: its review unit declares no dispatcher'
|
|
500
|
+
if (dispatcher === null) return 'nobody holds the dispatcher role: sk review dispatcher --as <name>'
|
|
501
|
+
if (who !== dispatcher) return `only the dispatcher (${dispatcher}) may ${op}`
|
|
502
|
+
}
|
|
503
|
+
if (!routed && s.state === 'new' && (op === 'claim' || op === 'fixed' || op === 'wontfix')) return op === 'claim' && who === 'owner' ? 'a worker claims, never the owner' : null
|
|
504
|
+
if (op === 'claim' && (who === 'owner' || who === dispatcher)) return `a worker claims, never ${who === 'owner' ? 'the owner' : 'the dispatcher'}`
|
|
505
|
+
if (WORKER_OPS.has(op) && who !== 'owner' && s.worker !== undefined && s.worker !== who) return `claimed by ${s.worker}, not by ${who}`
|
|
506
|
+
if (op === 'claim' && s.state === 'claimed' && s.since !== undefined && s.worker !== who) return `already claimed by ${s.worker}: release it first (--as ${s.worker})`
|
|
507
|
+
if (op === 'claim' && s.state === 'closed' && s.why !== 'rejected' && s.why !== 'approved-with-fix')
|
|
508
|
+
return `claim needs open, or a card he answered « fix » or « no », it is closed (${s.why})`
|
|
509
|
+
if (!FROM[op].includes(s.state)) {
|
|
510
|
+
if (op === 'claim' && s.state === 'claimed') return `already claimed by ${s.worker ?? 'an agent'}`
|
|
511
|
+
if (op === 'claim' && s.state === 'new') return 'not dispatched yet: only the dispatcher sends an item to a topic'
|
|
512
|
+
return `${op} needs ${FROM[op].join(' or ')}, it is ${s.state}${s.why ? ` (${s.why})` : ''}`
|
|
513
|
+
}
|
|
514
|
+
return null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** a claim with no message for this long is stale: the dispatcher may take it back, a rework stops showing */
|
|
518
|
+
export const STALE_CLAIM_MS = 2 * 60 * 60 * 1000
|
|
519
|
+
|
|
520
|
+
/** a claim no message touched for STALE_CLAIM_MS */
|
|
521
|
+
export const isStale = (x: ReviewItem, now: Date): boolean =>
|
|
522
|
+
stateOf(x, false).state === 'claimed' && now.getTime() - Date.parse(x.thread.at(-1)?.at ?? x.item.created) > STALE_CLAIM_MS
|
|
523
|
+
|
|
524
|
+
/** the lane working on an item now: it claimed it, no later card redoes it, the claim is not stale; `until` is when it
|
|
525
|
+
* goes stale with no new message. Null when nobody does */
|
|
526
|
+
export function reworkOf(x: ReviewItem, superseded: boolean, now: Date): { lane: string; since: string; until: number } | null {
|
|
527
|
+
const s = stateOf(x, superseded)
|
|
528
|
+
if (s.state !== 'claimed' || s.since === undefined || s.worker === undefined) return null
|
|
529
|
+
const until = Date.parse(x.thread.at(-1)?.at ?? s.since) + STALE_CLAIM_MS
|
|
530
|
+
return now.getTime() > until ? null : { lane: s.worker, since: s.since, until }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** an item the owner opened for marks only: every message a rating (an empty one is a marks item just opened) */
|
|
534
|
+
export const isMarkItem = (x: ReviewItem): boolean => x.item.origin.by === 'owner' && x.item.views.length === 0 && x.thread.every((m) => m.rate !== undefined || m.op === 'seen')
|
|
535
|
+
|
|
536
|
+
/** the lane an item belongs to: the lane that made a card, or the one he raised an item to */
|
|
537
|
+
export const laneOf = (x: ReviewItem): string | undefined => (x.item.origin.by === 'player' ? undefined : x.item.origin.lane)
|
|
538
|
+
|
|
539
|
+
/** what a watch keeps: one session's cards (`--mine`), one lane's items (`--lane`); null keeps all */
|
|
540
|
+
export interface WatchFilter {
|
|
541
|
+
session: string | null
|
|
542
|
+
lane: string | null
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** a live `sk review watch`: its process, its filter, when it started */
|
|
546
|
+
export interface Watcher extends WatchFilter {
|
|
547
|
+
pid: number
|
|
548
|
+
started: string
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** a watch with this filter reads his answers on `x` */
|
|
552
|
+
export const coversCard = (f: WatchFilter, x: ReviewItem): boolean =>
|
|
553
|
+
(f.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === f.session)) && (f.lane === null || laneOf(x) === f.lane)
|
|
554
|
+
|
|
555
|
+
/** the live watchers that will read his answer on any of these cards */
|
|
556
|
+
export const listenersOf = (cards: readonly ReviewItem[], watchers: readonly Watcher[]): Watcher[] => watchers.filter((w) => cards.some((x) => coversCard(w, x)))
|
|
557
|
+
|
|
558
|
+
/** a card's own session has this long after its last message to claim it before a watch calls it untaken */
|
|
559
|
+
export const UNTAKEN_GRACE_MS = 2 * 60 * 1000
|
|
560
|
+
/** a card nobody takes is told again this often */
|
|
561
|
+
export const UNTAKEN_AGAIN_MS = STALE_CLAIM_MS
|
|
562
|
+
|
|
563
|
+
/** why an item waits on the agents and nobody holds it, else null; `superseded`: a later card follows it */
|
|
564
|
+
export function untakenOf(x: ReviewItem, superseded: boolean, watchers: readonly Watcher[], now: Date, routed = true): string | null {
|
|
565
|
+
if (superseded) return null
|
|
566
|
+
const quiet = now.getTime() - Date.parse(x.thread.at(-1)?.at ?? x.item.created)
|
|
567
|
+
if (quiet < UNTAKEN_GRACE_MS) return null
|
|
568
|
+
const s = stateOf(x, false)
|
|
569
|
+
if (s.state === 'new') return routed ? 'raised, never dispatched' : 'raised, nobody claimed it'
|
|
570
|
+
if (s.state === 'open') return `raised${s.topic ? ` on ${s.topic}` : ''}, nobody claimed it`
|
|
571
|
+
if (s.state !== 'claimed') return null
|
|
572
|
+
if (s.since !== undefined) return quiet > STALE_CLAIM_MS ? `${s.worker ?? 'its'} claim went stale` : null
|
|
573
|
+
return listenersOf([x], watchers).length === 0 ? `the owner's verdict went back to ${s.worker ?? 'its author'}, and no watch reads it` : null
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export const ID_RE = /^\d{8}-\d{6}-[a-z0-9-]+$/
|
|
577
|
+
export const FILE_RE = /^((v\d+-)?((image|before|after|poster|wave|before-wave)\.(png|jpe?g|webp)|(video|before|after)\.(webm|mp4)|(audio|before|after)\.(wav|mp3|flac|ogg|opus|m4a))|v\d+-g\d+\.(png|jpe?g|webp))$/
|
|
578
|
+
const TILE_RE = /^v\d+-g\d+\.(png|jpe?g|webp)$/
|
|
579
|
+
const POSTER_RE = /^(v\d+-)?poster\.png$/
|
|
580
|
+
|
|
581
|
+
/** a clip by its extension: webm or mp4 */
|
|
582
|
+
export const isClipFile = (name: string): boolean => /\.(webm|mp4)$/i.test(name)
|
|
583
|
+
|
|
584
|
+
/** a sound by its extension */
|
|
585
|
+
export const isAudioFile = (name: string): boolean => /\.(wav|mp3|flac|ogg|opus|m4a)$/i.test(name)
|
|
586
|
+
|
|
587
|
+
const WAVE_RE = /^(v\d+-)?(wave|before-wave)\.png$/
|
|
588
|
+
|
|
589
|
+
export const isPair = (f: ReviewFiles): f is { before: string; after: string } => 'before' in f
|
|
590
|
+
|
|
591
|
+
/** what a card is about, as the list and the CLI name it: its topic, then its subject (once when they say the same) */
|
|
592
|
+
export function aboutText(i: Item, spec: Spec = NO_SPEC): string {
|
|
593
|
+
const subject = subjectLabel(i.subject.kind, spec)
|
|
594
|
+
const topic = i.topic ? topicLabel(i.topic, spec) : subject
|
|
595
|
+
return topic === subject ? subject : `${topic} · ${subject}`
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** the lowercase ascii words of a title */
|
|
599
|
+
export const wordsOf = (title: string): string[] =>
|
|
600
|
+
title
|
|
601
|
+
.normalize('NFD')
|
|
602
|
+
.replace(/\p{M}/gu, '')
|
|
603
|
+
.toLowerCase()
|
|
604
|
+
.split(/[^a-z0-9]+/)
|
|
605
|
+
.filter((w) => w !== '')
|
|
606
|
+
|
|
607
|
+
/** the lowercase ascii words of a title, for the id, cut on a word */
|
|
608
|
+
function slugOf(title: string): string {
|
|
609
|
+
let s = ''
|
|
610
|
+
for (const w of wordsOf(title)) {
|
|
611
|
+
if (s && s.length + 1 + w.length > 40) break
|
|
612
|
+
s = s ? `${s}-${w}` : w.slice(0, 40)
|
|
613
|
+
}
|
|
614
|
+
return s || 'request'
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** `YYYYMMDD-HHMMSS-<slug>`, local time */
|
|
618
|
+
export const idOf = (at: Date, title: string): string => `${stampOf(at)}-${slugOf(title)}`
|
|
619
|
+
|
|
620
|
+
const obj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
621
|
+
const unit = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
|
|
622
|
+
const EPS = 1e-6
|
|
623
|
+
/** a clip's last frame can read a hair past its length */
|
|
624
|
+
const EPS_T = 0.05
|
|
625
|
+
|
|
626
|
+
function subjectProblems(s: unknown, spec: Spec): string[] {
|
|
627
|
+
if (!obj(s) || typeof s.kind !== 'string' || s.kind === '') return ['subject: { kind }']
|
|
628
|
+
const kind = spec.kinds[s.kind]
|
|
629
|
+
const declared = Object.keys(spec.kinds)
|
|
630
|
+
if (!kind && !BUILTIN_KINDS[s.kind] && declared.length > 0) return [`subject: kind « ${s.kind} » is none of ${[...declared, ...Object.keys(BUILTIN_KINDS)].join(', ')}`]
|
|
631
|
+
if (kind?.problems) return kind.problems(s as Subject).map((p) => `subject: ${p}`)
|
|
632
|
+
if (s.kind !== 'frame') return []
|
|
633
|
+
const str = (k: string): boolean => typeof s[k] === 'string' && s[k] !== ''
|
|
634
|
+
const num = (k: string): boolean => typeof s[k] === 'number' && Number.isFinite(s[k])
|
|
635
|
+
return str('trailer') && str('cut') && str('shot') && num('time') && num('frame') && num('shotTime') ? [] : ['subject: a frame has trailer, cut, time, frame, shot, shotTime']
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** what the unit says is wrong with an item that reads well otherwise: its subject's kind and problems, its topic */
|
|
639
|
+
export function specProblems(i: Item, spec: Spec): string[] {
|
|
640
|
+
const out = subjectProblems(i.subject, spec)
|
|
641
|
+
const topics = Object.keys(spec.topics)
|
|
642
|
+
if (i.topic !== undefined && topics.length > 0 && !spec.topics[i.topic]) out.push(`topic: « ${i.topic} » is none of ${topics.join(', ')}`)
|
|
643
|
+
return out
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function originProblems(o: unknown): string[] {
|
|
647
|
+
if (!obj(o)) return ['origin: { by }']
|
|
648
|
+
const text = (k: string): string[] => (o[k] === undefined || typeof o[k] === 'string' ? [] : [`origin.${k}: a text`])
|
|
649
|
+
if (o.by === 'agent') return [...text('lane'), ...text('session')]
|
|
650
|
+
if (o.by === 'owner') return [...(typeof o.from === 'string' ? [] : ['origin.from: where he raised it']), ...text('lane')]
|
|
651
|
+
if (o.by === 'player')
|
|
652
|
+
return typeof o.player === 'string' && typeof o.server === 'string' && Number.isInteger(o.serverId) && typeof o.client === 'string' && obj(o.context)
|
|
653
|
+
? []
|
|
654
|
+
: ['origin: a player has player, server, serverId, client, context']
|
|
655
|
+
if (o.by === 'auto') return ['rule', 'key', 'lane'].every((k) => typeof o[k] === 'string' && o[k] !== '') ? [] : ['origin: an auto item has its rule, key and lane']
|
|
656
|
+
return ['origin.by: agent, owner, player or auto']
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** every reason an item.json read back is not an item; empty: it is one */
|
|
660
|
+
export function itemProblems(v: unknown): string[] {
|
|
661
|
+
if (!obj(v)) return ['not an object']
|
|
662
|
+
const out: string[] = []
|
|
663
|
+
for (const k of ['id', 'created', 'title']) if (typeof v[k] !== 'string' || v[k] === '') out.push(`${k}: a text`)
|
|
664
|
+
if (typeof v.id === 'string' && !ID_RE.test(v.id)) out.push(`id: ${v.id} is not YYYYMMDD-HHMMSS-slug`)
|
|
665
|
+
out.push(...subjectProblems(v.subject, NO_SPEC), ...originProblems(v.origin))
|
|
666
|
+
if (v.topic !== undefined && typeof v.topic !== 'string') out.push('topic: a text')
|
|
667
|
+
const agent = obj(v.origin) && v.origin.by === 'agent'
|
|
668
|
+
if (!Array.isArray(v.views) || (agent && v.views.length === 0)) out.push(`views: a list${agent ? ', the first a media view' : ''}`)
|
|
669
|
+
else
|
|
670
|
+
v.views.forEach((w: unknown, i: number) => {
|
|
671
|
+
if (i > 0 && (!obj(w) || typeof w.label !== 'string' || w.label.trim() === '')) out.push(`views[${i}].label: a text`)
|
|
672
|
+
if (obj(w) && w.gallery !== undefined) out.push(...(i === 0 ? ['views[0]: never a gallery'] : galleryProblems(w, `views[${i}].`)))
|
|
673
|
+
else out.push(...viewProblems(obj(w) ? w : {}, `views[${i}].`))
|
|
674
|
+
})
|
|
675
|
+
if (v.answers !== undefined && !(Array.isArray(v.answers) && v.answers.length > 0 && v.answers.every((a) => typeof a === 'string' && ID_RE.test(a))))
|
|
676
|
+
out.push('answers: a list of item ids')
|
|
677
|
+
for (const k of ['what', 'commit', 'supersedes', 'why', 'keeps']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
|
|
678
|
+
if (v.light !== undefined && v.light !== 'review') out.push('light: review, or absent')
|
|
679
|
+
if (v.wip !== undefined && !(obj(v.wip) && typeof v.wip.live === 'string' && v.wip.live !== '' && Number.isInteger(v.wip.frame))) out.push('wip: { live, frame }, or absent')
|
|
680
|
+
return out
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** every reason a thread file read back is not a message; empty: it is one */
|
|
684
|
+
export function messageProblems(v: unknown, name: string): string[] {
|
|
685
|
+
if (!obj(v)) return ['not an object']
|
|
686
|
+
const out: string[] = []
|
|
687
|
+
if (v.id !== name) out.push(`id: ${String(v.id)} is not its file name ${name}`)
|
|
688
|
+
if (typeof v.at !== 'string' || typeof v.who !== 'string' || v.who === '') out.push('at, who: texts')
|
|
689
|
+
if ('seen' in v) out.push('seen: a message of the old shape, before the one store')
|
|
690
|
+
if (v.op !== undefined && !OPS.includes(v.op as Op)) out.push(`op: one of ${OPS.join(', ')}`)
|
|
691
|
+
if (v.verdict !== undefined && !STATUSES.includes(v.verdict as ReviewStatus)) out.push(`verdict: one of ${STATUSES.join(', ')}`)
|
|
692
|
+
for (const k of ['text', 'image', 'note']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
|
|
693
|
+
if (v.op === 'seen' && !(Array.isArray(v.of) && v.of.every((x) => typeof x === 'string'))) out.push('of: the ids a seen message read')
|
|
694
|
+
if (v.op === undefined && v.verdict === undefined && v.rate === undefined && typeof v.text !== 'string' && !Array.isArray(v.rects))
|
|
695
|
+
out.push('a message says something: text, rects, verdict, rate or op')
|
|
696
|
+
return out
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** what is wrong with a gallery view: at least one image tile, each its file, its label and its size, and no media field */
|
|
700
|
+
function galleryProblems(v: Record<string, unknown>, at: string): string[] {
|
|
701
|
+
const g = v.gallery
|
|
702
|
+
if (!Array.isArray(g) || g.length === 0) return [`${at}gallery: a list of at least one { file, label, size }`]
|
|
703
|
+
const out: string[] = []
|
|
704
|
+
g.forEach((t: unknown, k: number) => {
|
|
705
|
+
const ok = obj(t) && typeof t.file === 'string' && TILE_RE.test(t.file) && typeof t.label === 'string' && obj(t.size) && Number(t.size.w) > 0 && Number(t.size.h) > 0
|
|
706
|
+
if (!ok) out.push(`${at}gallery[${k}]: { file: v<n>-g<k>.<png|jpg|webp>, label, size: { w, h } }`)
|
|
707
|
+
})
|
|
708
|
+
for (const k of ['files', 'size', 'clip', 'marks']) if (v[k] !== undefined) out.push(`${at}${k}: none on a gallery view`)
|
|
709
|
+
return out
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** what is wrong with one view's files, size and marks, each reason under `at` */
|
|
713
|
+
function viewProblems(v: Record<string, unknown>, at: string): string[] {
|
|
714
|
+
const out: string[] = []
|
|
715
|
+
if (typeof v.label !== 'string') out.push(`${at}label: a text`)
|
|
716
|
+
const f = v.files
|
|
717
|
+
const okImage = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && !isClipFile(n) && !isAudioFile(n)
|
|
718
|
+
const okClip = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isClipFile(n)
|
|
719
|
+
const okAudio = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isAudioFile(n)
|
|
720
|
+
const clipFiles = obj(f) && (okClip(f.video) || (okClip(f.before) && okClip(f.after)))
|
|
721
|
+
const audioFiles = obj(f) && (okAudio(f.audio) || (okAudio(f.before) && okAudio(f.after)))
|
|
722
|
+
if (!obj(f) || !(okImage(f.image) || (okImage(f.before) && okImage(f.after)) || clipFiles || audioFiles))
|
|
723
|
+
out.push(`${at}files: { image }, { video }, { audio } or { before, after }, a pair two images, two clips or two sounds`)
|
|
724
|
+
const a = v.audio
|
|
725
|
+
if (audioFiles) {
|
|
726
|
+
const pair = obj(f) && typeof f.before === 'string'
|
|
727
|
+
if (!obj(a) || !(Number(a.duration) > 0) || typeof a.wave !== 'string' || !WAVE_RE.test(a.wave) || (pair && (typeof a.waveBefore !== 'string' || !WAVE_RE.test(a.waveBefore))))
|
|
728
|
+
out.push(`${at}audio: { duration, wave${obj(f) && typeof f.before === 'string' ? ', waveBefore' : ''} } on a sound`)
|
|
729
|
+
if (v.marks !== undefined) out.push(`${at}marks: none on a sound`)
|
|
730
|
+
} else if (a !== undefined) out.push(`${at}audio: only on a sound`)
|
|
731
|
+
const s = v.size
|
|
732
|
+
if (!obj(s) || !(Number(s.w) > 0) || !(Number(s.h) > 0)) out.push(`${at}size: { w, h } in pixels`)
|
|
733
|
+
const c = v.clip
|
|
734
|
+
if (clipFiles) {
|
|
735
|
+
if (!obj(c) || typeof c.poster !== 'string' || !POSTER_RE.test(c.poster) || !(Number(c.duration) > 0) || !(Number(c.fps) > 0)) out.push(`${at}clip: { poster, duration, fps } on a clip`)
|
|
736
|
+
if (v.marks !== undefined) out.push(`${at}marks: none on a clip`)
|
|
737
|
+
} else if (c !== undefined) out.push(`${at}clip: only on a clip`)
|
|
738
|
+
if (v.marks !== undefined) {
|
|
739
|
+
const pair = obj(f) && typeof f.before === 'string'
|
|
740
|
+
const ok =
|
|
741
|
+
Array.isArray(v.marks) &&
|
|
742
|
+
v.marks.every(
|
|
743
|
+
(m: unknown) =>
|
|
744
|
+
obj(m) &&
|
|
745
|
+
Array.isArray(m.tile) &&
|
|
746
|
+
m.tile.length === 2 &&
|
|
747
|
+
m.tile.every(Number.isInteger) &&
|
|
748
|
+
(m.edge === undefined || ['n', 'e', 's', 'w'].includes(m.edge as string)) &&
|
|
749
|
+
unit(m.x) &&
|
|
750
|
+
unit(m.y) &&
|
|
751
|
+
unit(m.r) &&
|
|
752
|
+
(pair ? m.side === 'before' || m.side === 'after' : m.side === undefined),
|
|
753
|
+
)
|
|
754
|
+
if (!ok) out.push(`${at}marks: each { tile: [x, y], edge?, x, y, r } normalized, with a side on a pair`)
|
|
755
|
+
}
|
|
756
|
+
return out
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** the verdict the page sends for an item, checked: every rectangle inside its view, a side exactly on a pair */
|
|
760
|
+
export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions'> } | { ok: false; errors: string[] } {
|
|
761
|
+
if (!obj(body)) return { ok: false, errors: ['not an object'] }
|
|
762
|
+
const errors: string[] = []
|
|
763
|
+
if (!STATUSES.includes(body.status as ReviewStatus)) errors.push(`status: one of ${STATUSES.join(', ')}`)
|
|
764
|
+
if (body.comment !== undefined && typeof body.comment !== 'string') errors.push('comment: a text')
|
|
765
|
+
const rects = body.rects ?? []
|
|
766
|
+
if (!Array.isArray(rects)) errors.push('rects: a list')
|
|
767
|
+
const views = item.views
|
|
768
|
+
const out: Rect[] = []
|
|
769
|
+
for (const [i, r] of (Array.isArray(rects) ? rects : []).entries()) {
|
|
770
|
+
if (!obj(r) || !unit(r.x) || !unit(r.y) || !unit(r.w) || !unit(r.h) || r.w === 0 || r.h === 0) {
|
|
771
|
+
errors.push(`rects[${i}]: x, y, w, h between 0 and 1, w and h above 0`)
|
|
772
|
+
continue
|
|
773
|
+
}
|
|
774
|
+
if (r.x + r.w > 1 + EPS || r.y + r.h > 1 + EPS) errors.push(`rects[${i}]: leaves the image`)
|
|
775
|
+
if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`rects[${i}].comment: a text`)
|
|
776
|
+
const view = r.view === undefined ? 0 : r.view
|
|
777
|
+
const on = typeof view === 'number' && Number.isInteger(view) && (r.view === undefined || view >= 1) ? views[view] : undefined
|
|
778
|
+
if (!on) {
|
|
779
|
+
errors.push(`rects[${i}].view: the index of one of the card's other views (absent on the first)`)
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
if (isGallery(on)) {
|
|
783
|
+
errors.push(`rects[${i}].view: no rectangle on a gallery view, react to its tile instead`)
|
|
784
|
+
continue
|
|
785
|
+
}
|
|
786
|
+
if (on.audio) {
|
|
787
|
+
errors.push(`rects[${i}].view: no rectangle on a sound, react to the view instead`)
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
const pair = isPair(on.files)
|
|
791
|
+
if (pair && r.side !== 'before' && r.side !== 'after') errors.push(`rects[${i}].side: before or after, on a pair`)
|
|
792
|
+
if (!pair && r.side !== undefined) errors.push(`rects[${i}].side: none on a single image`)
|
|
793
|
+
const clip = on.clip
|
|
794
|
+
const t = r.t
|
|
795
|
+
if (clip && (typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > clip.duration + EPS_T)) errors.push(`rects[${i}].t: the clip time it was drawn at, 0 to ${clip.duration} s`)
|
|
796
|
+
if (!clip && t !== undefined) errors.push(`rects[${i}].t: none on an image`)
|
|
797
|
+
const rect: Rect = { x: r.x, y: r.y, w: r.w, h: r.h, comment: typeof r.comment === 'string' ? r.comment : '' }
|
|
798
|
+
if (view !== 0) rect.view = view as number
|
|
799
|
+
if (pair) rect.side = r.side as Side
|
|
800
|
+
if (clip && typeof t === 'number') rect.t = t
|
|
801
|
+
out.push(rect)
|
|
802
|
+
}
|
|
803
|
+
const reactions = reactionsOf(body.reactions, views, errors)
|
|
804
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
805
|
+
const comment = typeof body.comment === 'string' ? body.comment : ''
|
|
806
|
+
return {
|
|
807
|
+
ok: true,
|
|
808
|
+
message: {
|
|
809
|
+
verdict: body.status as ReviewStatus,
|
|
810
|
+
...(comment ? { text: comment } : {}),
|
|
811
|
+
...(out.length > 0 ? { rects: out } : {}),
|
|
812
|
+
...(reactions.length > 0 ? { reactions } : {}),
|
|
813
|
+
},
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** the reactions the page sends, checked against the card's views: each on a view, a tile only on a gallery, one per view
|
|
818
|
+
* or tile; an empty one is dropped */
|
|
819
|
+
function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[]): ReviewReaction[] {
|
|
820
|
+
if (v === undefined) return []
|
|
821
|
+
if (!Array.isArray(v)) {
|
|
822
|
+
errors.push('reactions: a list')
|
|
823
|
+
return []
|
|
824
|
+
}
|
|
825
|
+
const out: ReviewReaction[] = []
|
|
826
|
+
const seen = new Set<string>()
|
|
827
|
+
for (const [i, r] of v.entries()) {
|
|
828
|
+
const on = obj(r) && typeof r.view === 'number' && Number.isInteger(r.view) ? views[r.view] : undefined
|
|
829
|
+
if (!obj(r) || !on) {
|
|
830
|
+
errors.push(`reactions[${i}].view: the index of one of the card's views`)
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
const tiles = isGallery(on) ? on.gallery.length : 0
|
|
834
|
+
const tile = r.tile
|
|
835
|
+
if (tile !== undefined && !(typeof tile === 'number' && Number.isInteger(tile) && tile >= 0 && tile < tiles)) errors.push(`reactions[${i}].tile: the index of one tile of a gallery view`)
|
|
836
|
+
if (r.status !== undefined && !STATUSES.includes(r.status as ReviewStatus)) errors.push(`reactions[${i}].status: one of ${STATUSES.join(', ')}`)
|
|
837
|
+
if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`reactions[${i}].comment: a text`)
|
|
838
|
+
const key = `${String(r.view)}/${String(tile ?? '')}`
|
|
839
|
+
if (seen.has(key)) errors.push(`reactions[${i}]: a second reaction on the same view or tile`)
|
|
840
|
+
seen.add(key)
|
|
841
|
+
const comment = typeof r.comment === 'string' ? r.comment.trim() : ''
|
|
842
|
+
if (r.status === undefined && comment === '') continue
|
|
843
|
+
out.push({ view: r.view as number, ...(typeof tile === 'number' ? { tile } : {}), ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}), comment })
|
|
844
|
+
}
|
|
845
|
+
return out
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** what a reaction or a rectangle is on, as the CLI prints it: `[from above]`, `[trees / oak-v2]` */
|
|
849
|
+
export function placeText(r: { view?: number | undefined; tile?: number | undefined }, views: readonly ReviewView[]): string {
|
|
850
|
+
const i = r.view ?? 0
|
|
851
|
+
const v = views[i]
|
|
852
|
+
const tile = r.tile !== undefined && v && isGallery(v) ? ` / ${v.gallery[r.tile]?.label || `image ${r.tile + 1}`}` : ''
|
|
853
|
+
return `[${viewName(v, i)}${tile}]`
|
|
854
|
+
}
|