Let the conf say which control each setting deserves
The comments above a setting already state its choices, units and bounds, so the form can read them instead of asking for the file to be annotated first.
This commit is contained in:
@@ -234,6 +234,112 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename
|
||||
return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null;
|
||||
}
|
||||
|
||||
// Which control a field should be drawn with. Derived, never stored — the conf file stays the
|
||||
// only schema, so this reads what is already written there rather than asking for the file to be
|
||||
// annotated first. A key added tomorrow gets the right control with no code change and no markup.
|
||||
//
|
||||
// The order matters: an explicit statement in the comment beats a guess from the value, because
|
||||
// the guess cannot tell "false" the boolean from "false" the string a script compares against.
|
||||
//
|
||||
// Nothing here validates. Consistent with the rest of this file, it decides shape and leaves
|
||||
// correctness to the consuming script — a number field with min/max is a courtesy to whoever is
|
||||
// typing, not a promise that the value is sensible.
|
||||
function _vv_conf_widget(string $key, string $value, string $type, string $desc, array $descLines): array {
|
||||
// Arrays get the block editor whatever they contain. One value per line is already the right
|
||||
// control for a list, and there is no second guess worth making.
|
||||
if ($type !== 'scalar') return ['widget' => 'lines'];
|
||||
|
||||
// Secrets never render in the clear, whatever their value looks like. Reuses the same rule
|
||||
// the audit log redacts by, so a key cannot be a secret in the log and plain text in a form.
|
||||
if (vv_conf_key_is_secret($key)) return ['widget' => 'secret'];
|
||||
|
||||
// The convention already in the confs: each choice on its own comment line, quoted, then a
|
||||
// dash and what it does. It was written as documentation and happens to be a complete enum
|
||||
// declaration, which is exactly the property this file is built around.
|
||||
//
|
||||
// # "warn" — log warning and continue
|
||||
// # "abort" — refuse to continue (strict)
|
||||
// UNRAID_VERSION_MISMATCH_ACTION="warn"
|
||||
$choices = [];
|
||||
foreach ($descLines as $line) {
|
||||
if (preg_match('/^"([^"]+)"\s*(?:[—–-]\s*(.*))?$/u', trim($line), $m)) {
|
||||
$choices[] = ['value' => $m[1], 'hint' => trim($m[2] ?? '')];
|
||||
}
|
||||
}
|
||||
// A single quoted line is a quotation, not a choice list.
|
||||
if (count($choices) > 1) {
|
||||
// A value outside its own choice list is a misconfiguration, and dropping it to make the
|
||||
// list tidy would change the setting the moment the form saved. It is offered as a choice
|
||||
// instead, so the dropdown shows what is actually set and can also correct it.
|
||||
$have = array_column($choices, 'value');
|
||||
if (!in_array($value, $have, true)) {
|
||||
array_unshift($choices, ['value' => $value, 'hint' => 'current value — not one of the documented options']);
|
||||
}
|
||||
return ['widget' => 'enum', 'choices' => $choices];
|
||||
}
|
||||
|
||||
// The other convention already in the confs: the choices listed inline after the value, either
|
||||
// as the whole comment or after a colon.
|
||||
//
|
||||
// DIGEST_PROFILE="weekly" # always | smart | weekly
|
||||
// SONARR_DISCOVERY_MONITOR_MODE="all" # monitor mode on add: all | future | first | none
|
||||
//
|
||||
// Anchored to the end of the comment and required to contain the current value. The pattern is
|
||||
// loose enough to match ordinary prose containing a pipe, and "the value is one of these" is
|
||||
// the only signal that separates a choice list from a sentence.
|
||||
if (preg_match('/(?:^|:\s*)([A-Za-z0-9_.\-]+(?:\s*\|\s*[A-Za-z0-9_.\-]+)+)\s*$/', trim($desc), $m)) {
|
||||
$opts = array_map('trim', explode('|', $m[1]));
|
||||
if (count($opts) > 1 && in_array($value, $opts, true)) {
|
||||
return ['widget' => 'enum',
|
||||
'choices' => array_map(fn($o) => ['value' => $o, 'hint' => ''], $opts)];
|
||||
}
|
||||
}
|
||||
|
||||
// The escape hatch, for a setting whose options are not already spelled out above it. Written
|
||||
// to read as English so adding one does not turn the conf into a config language.
|
||||
if (preg_match('/\bone of:\s*([^.;#]+)/i', $desc, $m)) {
|
||||
$opts = array_values(array_filter(array_map('trim', preg_split('/\s*[,|]\s*/', $m[1])), 'strlen'));
|
||||
if (count($opts) > 1) {
|
||||
return ['widget' => 'enum',
|
||||
'choices' => array_map(fn($o) => ['value' => trim($o, '"\''), 'hint' => ''], $opts)];
|
||||
}
|
||||
}
|
||||
|
||||
$v = strtolower(trim($value));
|
||||
if ($v === 'true' || $v === 'false') return ['widget' => 'bool'];
|
||||
|
||||
// A file mode is digits and is not a number. A spinner invites arrowing 755 to 756, and a
|
||||
// number input normalises a leading zero away — so 0755 would save as 755, which is the same
|
||||
// mode today and a different one the day anything reads it as octal. Kept as plain text.
|
||||
if (preg_match('/(CHMOD|_MODE|PERM|UMASK)/', $key) && preg_match('/^0?[0-7]{3}$/', trim($value))) {
|
||||
return ['widget' => 'text'];
|
||||
}
|
||||
|
||||
if (preg_match('/^-?\d+$/', trim($value))) {
|
||||
$out = ['widget' => 'int'];
|
||||
// "clamped to 1-50", "between 1 and 50". Already written in several confs, so the range
|
||||
// that documents the setting also bounds the field.
|
||||
if (preg_match('/(?:clamped to|between)\s*(-?\d+)\s*(?:-|to|and)\s*(-?\d+)/i', $desc, $r)) {
|
||||
$out['min'] = (int) $r[1];
|
||||
$out['max'] = (int) $r[2];
|
||||
}
|
||||
// The house style opens an inline comment with the unit — "# seconds — probe when
|
||||
// resolving which node has Ollama" — so the unit is already there to be shown beside
|
||||
// the box instead of buried in the description.
|
||||
if (preg_match('/^\s*(seconds?|minutes?|hours?|days?|weeks?|months?|percent|%|[KMGT]B|'
|
||||
. 'chars?|characters?|tokens?|conversations?|entries|items|lines|runs|'
|
||||
. 'attempts|retries|consecutive)\b/i', $desc, $u)) {
|
||||
$out['unit'] = strtolower($u[1]);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// A path gets a wider box and no monospace surprises. ${VAR}/... is the established idiom.
|
||||
if (str_starts_with($value, '/') || str_contains($value, '${')) return ['widget' => 'path'];
|
||||
|
||||
return ['widget' => 'text'];
|
||||
}
|
||||
|
||||
// Parse all config fields between two line indices. Shared by vv_conf_parse_subsection()
|
||||
// (per-script editor) and vv_conf_all_groups() (full settings view).
|
||||
function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array {
|
||||
@@ -255,6 +361,10 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
|
||||
}
|
||||
|
||||
$desc = implode(' ', $pendingDesc);
|
||||
// Kept as lines as well as joined. The enum convention lives in the line breaks — one
|
||||
// quoted choice per comment line — and joining them destroys the only thing that
|
||||
// distinguishes a choice list from a paragraph that happens to contain quotes.
|
||||
$descLines = $pendingDesc;
|
||||
$pendingDesc = [];
|
||||
|
||||
// declare -A KEY=(
|
||||
@@ -297,17 +407,30 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
|
||||
// Scalar: KEY="value" or KEY=value
|
||||
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
|
||||
$fields[] = ['key' => $m[1], 'value' => $m[2],
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
|
||||
'_lines' => $descLines];
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
|
||||
$val = trim($m[2]);
|
||||
if ($val === '') continue;
|
||||
$fields[] = ['key' => $m[1], 'value' => $val,
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
|
||||
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
|
||||
'_lines' => $descLines];
|
||||
}
|
||||
}
|
||||
|
||||
// Widget is added in one pass rather than at each of the five construction sites above, so
|
||||
// every field type goes through the same rules and a new type cannot quietly skip them.
|
||||
foreach ($fields as &$f) {
|
||||
$f += _vv_conf_widget((string) $f['key'], (string) $f['value'], (string) $f['type'],
|
||||
(string) $f['desc'], (array) ($f['_lines'] ?? []));
|
||||
// Internal to this pass. Left in, it would ride out to the browser on every field as a
|
||||
// second, subtly different copy of the description.
|
||||
unset($f['_lines']);
|
||||
}
|
||||
unset($f);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
@@ -367,8 +490,19 @@ function vv_conf_key_valid(string $key): bool {
|
||||
}
|
||||
|
||||
// Credential-shaped keys are logged by name only. The audit log is the record that a change
|
||||
// happened, not a second copy of the secret that changed.
|
||||
// happened, not a second copy of the secret that changed. Also decides which fields render
|
||||
// masked, so the same key cannot be redacted in the log and legible in a form.
|
||||
//
|
||||
// The match is deliberately loose — the second alternative is unanchored, so anything merely
|
||||
// CONTAINING "token" or "pass" counts. Over-redacting a setting costs a little readability;
|
||||
// under-redacting one puts a credential in a log file, so the bias is correct and stays.
|
||||
//
|
||||
// The exception exists because one family trips it on a word that means something else entirely:
|
||||
// AI_TOKEN_* is LLM token accounting — a path, a row cap, a switch — and nothing there is a
|
||||
// credential. Named as a literal prefix rather than by relaxing the rule, so this cannot widen
|
||||
// into "token sometimes means tokens" and quietly expose a real one.
|
||||
function vv_conf_key_is_secret(string $key): bool {
|
||||
if (str_starts_with($key, 'AI_TOKEN_')) return false;
|
||||
return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user