['Array Start'], 'Orchestrators/array_stopping.sh' => ['Array Stop'], 'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'], 'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'], 'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'], 'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'], 'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'], 'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'], 'Orchestrators/transcode_management.sh' => ['Transcode Manager', 'Transcode Server Array', 'Transcodes'], // Docker Essentials 'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'], 'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'], 'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'], 'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'], // Watchdogs 'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'], 'Watchdogs/resource_watchdog.sh' => ['Pressure Levels'], 'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'], 'Plugin/unraid/Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'], // Media 'Media/media_cleaner.sh' => ['Media Cleaner'], 'Media/media_shares_permissions.sh' => ['Media Permissions'], // Arrs Stack 'Arrs_Stack/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery'], 'Arrs_Stack/radarr_cleanup.sh' => ['Arr Cleanup'], 'Arrs_Stack/lidarr_cleanup.sh' => ['Arr Cleanup'], 'Arrs_Stack/sonarr_cleanup.sh' => ['Arr Cleanup'], // Monitors 'Monitors/cert_monitor.sh' => ['Certificate Monitor'], 'Monitors/backup_verify.sh' => ['Backup Verify'], 'Monitors/smart_health.sh' => ['SMART Health'], 'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'], 'Monitors/emby_session_report.sh' => ['Emby Session Report'], 'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report'], ]; function vv_conf_has_sections(string $id): bool { return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []); } // Parse fields from a named subsection in raw conf content. // Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars). // Returns array of field defs, or null if subsection not found. function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array { $lines = explode("\n", $raw); $n = count($lines); $needle = mb_strtolower(trim(preg_replace('/\s+/', ' ', $subName))); $start = -1; for ($i = 0; $i < $n; $i++) { if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) continue; $t = mb_strtolower(trim(preg_replace('/\s+/', ' ', $m[1]))); if ($t === $needle) { $start = $i + 1; break; } } if ($start === -1) return null; // End at next section/subsection line (3+ consecutive divider chars after #) $end = $n; for ($i = $start; $i < $n; $i++) { if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; } } return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null; } // 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 { $fields = []; $pendingDesc = []; for ($i = $start; $i < $end; $i++) { $line = rtrim($lines[$i]); if ($line === '' || $line === '#') { $pendingDesc = []; continue; } // Pure comment line if (preg_match('/^#\s*(.*)$/', $line, $cm)) { $inner = trim($cm[1]); if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) { $pendingDesc[] = $inner; } continue; } $desc = implode(' ', $pendingDesc); $pendingDesc = []; // declare -A KEY=( if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) { $indent = $m[1]; $key = $m[2]; $blockLines = []; $j = $i + 1; while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) { $blockLines[] = rtrim($lines[$j]); $j++; } $fields[] = ['key' => $key, 'value' => implode("\n", $blockLines), 'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent]; $i = $j; continue; } // KEY=( (array) if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) { $indent = $m[1]; $key = $m[2]; // Single-line: KEY=( ... ) if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) { $fields[] = ['key' => $key, 'value' => $sm[1], 'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent]; continue; } // Multi-line $blockLines = []; $j = $i + 1; while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) { $blockLines[] = rtrim($lines[$j]); $j++; } $fields[] = ['key' => $key, 'value' => implode("\n", $blockLines), 'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent]; $i = $j; continue; } // 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]; 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]; } } return $fields; } // Return ALL config groups (every named section + its fields) for a conf file. // Enumerates header lines (# ━━━ Name ━━━ or # ── Name ──); each group runs from its // header to the next named header so major sections (sandwiched in ===) capture their // settings too. Empty groups (divider-only headers) are dropped. function vv_conf_all_groups(string $filename): array { $raw = vv_read_conf_raw($filename); if ($raw === '') return []; $lines = explode("\n", $raw); $n = count($lines); $headers = []; for ($i = 0; $i < $n; $i++) { if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) { $headers[] = ['name' => trim(preg_replace('/\s+/', ' ', $m[1])), 'line' => $i]; } } $groups = []; foreach ($headers as $idx => $h) { $start = $h['line'] + 1; $end = $headers[$idx + 1]['line'] ?? $n; $fields = _vv_conf_parse_field_range($lines, $start, $end, $filename); if ($fields) { $groups[] = ['subsection' => $h['name'], 'file' => $filename, 'fields' => $fields]; } } return $groups; } // Return all conf groups (subsection + fields) for a script on the current host. function vv_conf_fields_for_script(string $id): array { $sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? []; if (!$sectionNames) return []; $groups = []; foreach ($sectionNames as $name) { foreach (vv_get_conf_files() as $filename) { $fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename); if ($fields !== null) { $groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields]; } } } return $groups; } // Write a batch of field changes back to their respective conf files. // Each change: {file, key, value, type} function vv_conf_write_changes(array $changes): array { $byFile = []; foreach ($changes as $c) { if (!empty($c['file']) && !empty($c['key'])) $byFile[$c['file']][] = $c; } $results = []; foreach ($byFile as $file => $fileChanges) { $raw = vv_read_conf_raw($file); if ($raw === '') { $results[$file] = false; continue; } foreach ($fileChanges as $c) { $qKey = preg_quote($c['key'], '/'); $value = $c['value']; $type = $c['type'] ?? 'scalar'; if ($type === 'scalar') { $raw = preg_replace_callback( '/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m', fn($m) => $m[1] . '"' . str_replace(['"', '\\'], ['\\"', '\\\\'], $value) . '"' . $m[3], $raw ) ?? $raw; } elseif ($type === 'array_single') { $raw = preg_replace_callback( '/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m', fn($m) => $m[1] . $value . $m[3], $raw ) ?? $raw; } elseif ($type === 'array') { $raw = preg_replace_callback( '/^(\s*)(' . $qKey . '\s*=\s*\()[^)]*\)/ms', fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")", $raw ) ?? $raw; } elseif ($type === 'assoc_array') { $raw = preg_replace_callback( '/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()[^)]*\)/ms', fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")", $raw ) ?? $raw; } } // Only the scalar path escapes its value; the array, array_single and assoc_array // paths splice the caller's text into the file verbatim, and the type comes from the // request. Every script sources these files, so the result is parsed before it is // allowed to replace a working conf. $results[$file] = vv_conf_syntax_ok($raw) && vv_write_conf_raw($file, $raw); } return $results; } // bash -n against a private temp copy. Returns true when the content parses as a sourceable // conf, false otherwise — never writes anything itself. function vv_conf_syntax_ok(string $content): bool { $tmp = tempnam(sys_get_temp_dir(), 'vvconf'); if ($tmp === false) return true; // cannot check — do not block the write file_put_contents($tmp, $content); $out = []; $rc = 0; exec('bash -n ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc); @unlink($tmp); return $rc === 0; }