feat: copy/paste per-class auto-annotate overrides as YAML (REQ-186, REQ-190)

Copy now emits YAML instead of the one-line "class <name> conf ..." text: one
block per selected class, fields named after the row labels, globals resolved
into the block, a name quoted only when YAML would misread it.

Paste reads the clipboard back and fills the overrides for the classes selected
in that modal, naming any it ignored — an override for a deselected class would
otherwise ride along in class_params unseen. container diffs go out as one
PATCH /projects/{id} and revert together if the save is rejected.

Parsing is parseClassYaml, a strict subset reader for exactly what Copy emits,
so no YAML dependency: any malformed line throws with its line number and
applies nothing. Values must look like numbers, not merely start like them —
parseFloat would have taken "0.5abc" as 0.5.

Both are real buttons with Lucide icons plus a status line reporting the
outcome, and both live in the shared table so the mass modal carries them too.
This commit is contained in:
asus committed 2026-10-05 14:17:17 +07:00
1 parent 4f15335092
commit 4575c46f4c
8 files changed
+344 -49

No files matched your search

+33
View File
@@ -657,6 +657,39 @@ main.page {
font-size: 0.78rem;
}
/* Copy/Paste YAML above the per-class override rows (REQ-190). */
.class-params-tools {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.class-params-tools .btn {
padding: 4px 10px;
font-size: 0.76rem;
cursor: pointer;
}
.class-params-tools .btn:disabled {
cursor: progress;
opacity: 0.6;
}
.class-params-note {
/* Not .hint: --text-faint is 2.7:1 on the modal panel, this text needs 4.5:1. */
color: var(--text-muted);
font-size: 0.72rem;
text-align: right;
min-width: 0;
}
.class-params-note[data-failed] {
color: var(--danger);
}
.review-side {
display: flex;
flex-direction: column;
+13
View File
@@ -1,6 +1,8 @@
// Clipboard API needs a secure context — on http://<ip>:9000 it is undefined.
// execCommand('copy') still works there inside a click gesture; if both fail,
// the path is revealed in a pre-selected field so one Ctrl+C always works.
// Reading has no legacy fallback (execCommand('paste') is not a thing), so it
// reports why instead of failing quietly.
function legacyCopy(text) {
const area = document.createElement('textarea')
area.value = text
@@ -34,3 +36,14 @@ export async function copyText(text) {
return false
}
}
export async function readClipboardText() {
if (!navigator.clipboard?.readText) {
throw new Error('Clipboard read is blocked here — open the app on localhost or https to paste')
}
try {
return await navigator.clipboard.readText()
} catch {
throw new Error('Clipboard read was denied — allow it in the browser prompt, then paste again')
}
}
+159 -31
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { api } from '../api'
import { copyText } from '../clipboard'
import { copyText, readClipboardText } from '../clipboard'
import { ClipboardPasteIcon, CopyIcon } from './Icons'
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
const KEYS = [
@@ -10,6 +11,59 @@ const KEYS = [
{ key: 'max_box_frac', label: 'MaxBox', step: 0.05, color: '#f472b6' },
]
// The YAML wire format uses the labels as written on screen (REQ-190).
const FIELD_BY_LABEL = Object.fromEntries(KEYS.map(({ key, label }) => [label.toLowerCase(), key]))
// A class name only needs quoting when YAML would misread it.
const PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
const yamlName = (name) => (PLAIN_NAME.test(name) ? name : `"${name.replace(/["\\]/g, '\\$&')}"`)
// parseFloat would take `0.5abc` as 0.5 and hide the typo, so the text has to
// look like a number first. Inline `# comment` is stripped from the value only,
// never from the key — a quoted class name may legitimately contain " #".
const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/
const TRUE_FALSE = { true: true, yes: true, '1': true, false: false, no: false, '0': false }
// Strict reader for exactly what compose() emits: `#` comments and blank lines,
// a class name at the left margin, `key: value` indented under it. Anything
// else throws with its line number so a bad paste changes nothing at all.
export function parseClassYaml(text) {
const classes = {}
let current = null
text.split(/\r?\n/).forEach((raw, i) => {
const line = raw.trim()
const at = `line ${i + 1}`
if (!line || line.startsWith('#')) return
const match = line.match(/^(".*"|'.*'|[^:]+):\s*(.*)$/)
if (!match) throw new Error(`${at}: expected "class:" or "key: value"`)
let name = match[1].trim()
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(["\\])/g, '$1')
else if (name.startsWith("'") && name.endsWith("'")) name = name.slice(1, -1).replace(/''/g, "'")
const value = match[2].replace(/\s+#.*$/, '').trim()
if (!/^[ \t]/.test(raw)) {
if (value) throw new Error(`${at}: unexpected value "${value}"`)
current = name
classes[name] = classes[name] || {}
return
}
if (!current) throw new Error(`${at}: "${name}" before any class name`)
if (!value) throw new Error(`${at}: nested values are not supported`)
if (name === 'container') {
const flag = TRUE_FALSE[value.toLowerCase()]
if (flag === undefined) throw new Error(`${at}: container must be true or false, got "${value}"`)
classes[current].container = flag
return
}
const field = FIELD_BY_LABEL[name]
if (!field) throw new Error(`${at}: unknown key "${name}"`)
if (!NUMBER.test(value)) throw new Error(`${at}: ${name} must be a number, got "${value}"`)
const num = parseFloat(value)
if (num < 0 || num > 1) throw new Error(`${at}: ${name} must be between 0 and 1, got ${value}`)
classes[current][field] = num
})
return classes
}
export function buildClassParams(classParams) {
const out = {}
for (const [name, values] of Object.entries(classParams || {})) {
@@ -32,28 +86,47 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
const [containers, setContainers] = useState(() =>
new Set((project?.classes || []).filter(c => c.container).map(c => c.name))
)
const [copied, setCopied] = useState(false)
const [note, setNote] = useState({ text: '', failed: false })
const [busy, setBusy] = useState(false)
const say = (text, failed = false) => {
setNote({ text, failed })
if (!failed) setTimeout(() => setNote((n) => (n.text === text ? { text: '', failed: false } : n)), 2000)
}
const set = (name, key, raw) =>
onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } })
const toggleContainer = async (cls) => {
const on = !containers.has(cls.name)
const next = new Set(containers)
if (on) next.add(cls.name)
else next.delete(cls.name)
// One PATCH for every flag that changed, reverted together on rejection.
const applyContainers = async (next, changes) => {
const patch = {}
for (const [name, on] of changes) {
const cls = project?.classes.find((c) => c.name === name)
if (cls) patch[cls.class_id] = on
}
setContainers(next)
if (!Object.keys(patch).length) return true
try {
await api.patchProject(project.id, { containers: { [cls.class_id]: on } })
await api.patchProject(project.id, { containers: patch })
return true
} catch (err) {
// Functional revert: only undo this toggle, even if another landed meanwhile.
setContainers(prev => {
console.error('Could not save the container flags:', err)
setContainers((prev) => {
const reverted = new Set(prev)
if (on) reverted.delete(cls.name)
else reverted.add(cls.name)
for (const [name, on] of changes) {
if (on) reverted.delete(name)
else reverted.add(name)
}
return reverted
})
console.error('Could not save the container flag:', err)
return false
}
}
const toggleContainer = (cls) =>
applyContainers(
containers.has(cls.name)
? new Set([...containers].filter((n) => n !== cls.name))
: new Set(containers).add(cls.name),
[[cls.name, !containers.has(cls.name)]]
)
if (!classNames.length) return null
const fmt = (v) => String(Math.round(v * 1000) / 1000)
const effective = (name, key) => {
@@ -62,35 +135,90 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
return Number.isFinite(num) ? num : globals[key]
}
const compose = () =>
classNames
.map(
(name) =>
`class ${name} conf ${fmt(effective(name, 'threshold'))} iou ${fmt(
effective(name, 'iou_threshold')
)} minbox ${fmt(effective(name, 'min_box_frac'))} maxbox ${fmt(
effective(name, 'max_box_frac')
)} container ${containers.has(name)}`
)
.join('\n')
[
'# auto-annotate per-class overrides',
...classNames.map((name) =>
[
`${yamlName(name)}:`,
...KEYS.map(({ key, label }) => ` ${label.toLowerCase()}: ${fmt(effective(name, key))}`),
` container: ${containers.has(name)}`,
].join('\n')
),
].join('\n')
const copy = async () => {
const ok = await copyText(compose())
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} else {
if (ok) say('Copied')
else {
console.error('Could not copy the effective auto-annotate parameters')
say('Could not copy — your browser refused the clipboard', true)
}
}
const paste = async () => {
setBusy(true)
try {
const parsed = parseClassYaml(await readClipboardText())
const names = Object.keys(parsed)
if (!names.length) throw new Error('No classes in the pasted YAML')
const known = names.filter((name) => classNames.includes(name))
if (!known.length) throw new Error(`None of these classes are selected here: ${names.join(', ')}`)
const unknown = names.filter((name) => !classNames.includes(name))
// Only selected classes: an override for a deselected class would ride
// along in class_params unseen (buildClassParams walks all of them).
const next = { ...value }
const flags = new Set(containers)
const changes = []
for (const name of known) {
const merged = { ...(next[name] || {}) }
for (const { key } of KEYS) {
if (parsed[name][key] !== undefined) merged[key] = String(parsed[name][key])
}
next[name] = merged
const on = parsed[name].container
if (on !== undefined && on !== flags.has(name)) {
if (on) flags.add(name)
else flags.delete(name)
changes.push([name, on])
}
}
onChange(next)
const saved = await applyContainers(flags, changes)
const suffix = unknown.length ? ` — ignored ${unknown.join(', ')}` : ''
say(
saved
? `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
: `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix} — container flags not saved`,
!saved
)
} catch (err) {
say(err.message, true)
} finally {
setBusy(false)
}
}
return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div className="class-params-tools">
<button
className="btn btn-ghost"
type="button"
className="btn"
onClick={copy}
style={{ fontSize: '0.76rem', padding: '4px 8px', cursor: 'pointer' }}
disabled={busy}
title="Copy these per-class overrides as YAML"
>
<span aria-live="polite">{copied ? 'Copied' : 'Copy'}</span>
<CopyIcon /> Copy YAML
</button>
<button
type="button"
className="btn"
onClick={paste}
disabled={busy}
title="Fill these overrides from YAML on the clipboard"
>
<ClipboardPasteIcon /> Paste YAML
</button>
<span className="class-params-note" role="status" data-failed={note.failed || undefined}>
{note.text}
</span>
</div>
{/* Stacked rows (REQ-181): the name line carries the container flag, the
grid below it the four overrides, so a wide class list never squeezes
+15
View File
@@ -95,6 +95,21 @@ export const CpuIcon = (props) => (
</Icon>
)
export const CopyIcon = (props) => (
<Icon {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</Icon>
)
export const ClipboardPasteIcon = (props) => (
<Icon {...props}>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M12 11v4M10 13h4" />
</Icon>
)
export const RocketIcon = (props) => (
<Icon {...props}>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" />