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:
1 parent
4f15335092
commit
4575c46f4c
8 files changed
+344
-49
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user