Files
Varaverk/Plugin/unraid/include/scheduler.php
T
Gmer4Lfe f42ecc8464 Add docker actions, arr profile enforcer, monitor caching, and web file symlink
Web files now served via symlink to the git repo so git pull changes survive
reboots without rebuilding the txz. Also includes: docker pull/rebuild/restart
with live log streaming, arr_profile_enforcer for Sonarr/Radarr quality
profiles, monitor page cache fix (background writer now in cron), and
ARR_KIDS/SONARR/RADARR profile name vars in master.conf.
2026-06-19 11:09:40 -04:00

678 lines
28 KiB
PHP

<?php
// Scheduler — manages schedule.json and the Unraid plugin cron file.
// schedule.json is per-host, never synced.
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('SCHEDULE_FILE', SCRIPTS_DIR . '/schedule.json');
define('CRON_FILE', '/boot/config/plugins/varaverk/varaverk.cron'); // must stay in /boot — update_cron scans there
function vv_pretty_label(string $slug): string {
return ucwords(str_replace('_', ' ', $slug));
}
function vv_schedule_load(): array {
if (!file_exists(SCHEDULE_FILE)) return [];
$data = json_decode(file_get_contents(SCHEDULE_FILE), true);
return is_array($data) ? $data : [];
}
function vv_schedule_save(array $schedule): bool {
$dir = dirname(SCHEDULE_FILE);
if (!is_dir($dir)) mkdir($dir, 0755, true);
return file_put_contents(SCHEDULE_FILE, json_encode($schedule, JSON_PRETTY_PRINT)) !== false;
}
function vv_schedule_update(string $id, bool $enabled, string $cron, bool $log_enabled = false): bool {
$schedule = vv_schedule_load();
$schedule[$id] = [
'id' => $id,
'enabled' => $enabled,
'cron' => $cron,
'log_enabled' => $log_enabled,
'updated' => date('c'),
];
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_schedule_update_batch(array $entries): bool {
$schedule = vv_schedule_load();
foreach ($entries as $e) {
$id = trim($e['id'] ?? '');
if (!$id) continue;
$schedule[$id] = [
'id' => $id,
'enabled' => (bool)($e['enabled'] ?? false),
'cron' => trim($e['cron'] ?? ''),
'log_enabled' => (bool)($e['log_enabled'] ?? false),
'updated' => date('c'),
];
}
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_job_flags(string $id): string {
$schedule = vv_schedule_load();
return !empty($schedule[$id]['log_enabled']) ? '--log' : '';
}
function vv_job_log_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.log', $id);
}
function vv_job_stat_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.json', $id);
}
function vv_cron_rebuild(array $schedule): bool {
if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true);
$runner = dirname(__DIR__) . '/run_job.sh';
$lines = ["# Varaverk — managed by plugin, do not edit manually"];
$lines[] = "# Regenerated: " . date('Y-m-d H:i:s');
$lines[] = "";
// Build child→orch map so we can suppress a child's independent cron when its orch is enabled.
$childToOrch = [];
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (glob(SCRIPTS_DIR . '/Orchestrators/*.sh') ?: [] as $orchPath) {
$orchId = 'Orchestrators/' . basename($orchPath);
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/', $content, $m1);
foreach ($m1[1] as $rel) $childToOrch[$rel] = $orchId;
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
foreach (array_unique($refs[1] ?? []) as $var) {
foreach (vv_parse_conf_array_full($confRaw, $var) as $item) $childToOrch[$item['path']] = $orchId;
}
}
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $entry) {
if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
// Event-triggered jobs are handled by static event scripts, not cron.
if (str_starts_with($entry['cron'], 'array_')) continue;
$id = $entry['id'];
// When a child's orch is enabled it is the sole trigger — suppress independent cron.
if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
$script = "$scriptsDir/$id";
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
}
$lines[] = "";
// Standalone rsync entries: fire when orch is disabled but location + cron are both configured.
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $key => $entry) {
if (!str_starts_with((string)$key, '__rsync_')) continue;
$orchId = $entry['orch_id'] ?? '';
$location = $entry['location'] ?? '';
$cron = $entry['cron'] ?? '';
if (!$orchId || !$location || !$cron) continue;
// Skip if orch is still enabled
if (!empty($schedule[$orchId]['enabled'])) continue;
$rsyncScript = "$scriptsDir/Rsync/rsync.sh";
if (!file_exists($rsyncScript)) continue;
$locArg = escapeshellarg('--location=' . $location);
$logFlag = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "$cron bash \"$runner\" \"Rsync/rsync.sh\" \"$rsyncScript\" $locArg$logFlag";
}
$lines[] = "";
// Background writers — always injected, never user-configurable (excluded from scheduler UI).
$toolsDir = SCRIPTS_DIR . '/Plugin/unraid/Tools';
foreach ([
['* * * * *', 'api_cache_writer.sh'],
['0 */2 * * *', 'remote_arr_cache_writer.sh'],
] as [$cron, $script]) {
$path = "$toolsDir/$script";
if (file_exists($path)) $lines[] = "$cron bash \"$runner\" \"Plugin/unraid/Tools/$script\" \"$path\"";
}
$lines[] = "";
// Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
exec('/usr/local/sbin/update_cron');
// Remove legacy direct cron file left from before update_cron migration — prevents duplicate job firing.
@unlink('/etc/cron.d/varaverk');
return true;
}
// Extract the suggested cron and label from a bash script header.
// Looks for: # Schedule: */7 * * * * (every 7 minutes via User Scripts plugin)
function vv_script_suggested_cron(string $path): array {
if (!file_exists($path)) return ['cron' => '', 'label' => ''];
$lines = array_slice(file($path) ?: [], 0, 30);
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (!preg_match('/^#\s*Schedule:\s*(.+)$/i', $raw, $m)) continue;
$tail = trim($m[1]);
$parts = preg_split('/\s+/', $tail, 6);
$cron = implode(' ', array_slice($parts, 0, 5));
$label = isset($parts[5]) ? trim($parts[5], '() ') : '';
return ['cron' => $cron, 'label' => $label];
}
return ['cron' => '', 'label' => ''];
}
// Parse user_script_plug-in.sh into an array of script blocks.
// Each block: title, schedule, desc (array of lines), scripts (array of {rel, cron})
function vv_parse_user_script_template(): array {
$file = SCRIPTS_DIR . '/Plugin/unraid/user_script_plug-in.sh';
if (!file_exists($file)) return [];
$lines = file($file, FILE_IGNORE_NEW_LINES);
$prefix = rtrim(SCRIPTS_DIR, '/') . '/';
$blocks = [];
$cur = null;
foreach ($lines as $line) {
// Block header: # ── TITLE ──...
if (preg_match('/^# ── (.+?) ─/', $line, $m)) {
if ($cur) $blocks[] = $cur;
$cur = ['title' => trim($m[1]), 'schedule' => '', 'desc' => [], 'scripts' => []];
continue;
}
if (!$cur) continue;
// Schedule / Background — captured but not added to desc
if (preg_match('/^# (Schedule|Background):\s+(.+)$/i', $line, $m)) {
if (strtolower($m[1]) === 'schedule') $cur['schedule'] = trim($m[2]);
continue;
}
// Sunday block inline cron: # 0 6 * * 0 bash /path/script.sh [args]
if (preg_match('/^#\s+(\S+ +\S+ +\S+ +\S+ +\S+)\s+bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', trim($m[2]));
$cur['scripts'][] = ['rel' => $rel, 'cron' => preg_replace('/\s+/', ' ', trim($m[1]))];
continue;
}
// Standard bash line: # bash /prefix/path/script.sh [args]
if (preg_match('/^# bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', $m[1]);
// Only add primary command (dedupe by rel)
$rels = array_column($cur['scripts'], 'rel');
if (!in_array($rel, $rels)) {
$cur['scripts'][] = ['rel' => $rel, 'cron' => ''];
}
continue;
}
// Description line
if (preg_match('/^# ?(.*)$/', $line, $m)) {
$inner = $m[1];
if (!preg_match('/^[─━=\-]{3,}\s*$/', $inner) && !preg_match('/^█/', $inner)) {
$cur['desc'][] = $inner;
}
}
}
if ($cur) $blocks[] = $cur;
return $blocks;
}
// Extract a one-line description from a bash script header.
// Supports two patterns:
// 1. # PURPOSE (or # DESCRIPTION) block — returns first non-separator line after it
// 2. First meaningful comment line after the banner
function vv_script_description(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 50);
$purposeNext = false;
$first = '';
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (str_starts_with($raw, '#!')) continue; // shebang
if (!str_starts_with($raw, '#')) continue; // non-comment
$inner = ltrim(substr($raw, 1)); // strip leading #
if ($inner === '') continue; // blank
if (preg_match('/^[\s=\-─━\*]+$/', $inner)) continue; // pure separator
if (preg_match('/^={3,}/', $inner)) continue; // banner (=== Title ===)
if (preg_match('/^\s*(PURPOSE|DESCRIPTION|Description)\s*$/i', $inner)) {
$purposeNext = true;
continue;
}
if ($purposeNext) {
return mb_substr(trim($inner), 0, 200);
}
if (!$first) $first = mb_substr(trim($inner), 0, 200);
}
return $first;
}
function vv_tools_scripts(): array {
// Background writers managed automatically — not user-facing tools
static $EXCLUDE = ['api_cache_writer.sh', 'remote_arr_cache_writer.sh'];
$schedule = vv_schedule_load();
$scripts = [];
$collect = function(string $dir, string $relPrefix) use ($schedule, $EXCLUDE, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$base = basename($path);
if (in_array($base, $EXCLUDE, true)) continue;
$rel = $relPrefix . $base;
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
};
// General tools
$collect(SCRIPTS_DIR . '/Tools', 'Tools/');
// Platform adapter tools (Plugin/<platform>/Tools/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Tools') ?: [] as $toolsDir) {
$platform = basename(dirname($toolsDir));
$collect($toolsDir, "Plugin/$platform/Tools/");
}
usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label']));
return $scripts;
}
function vv_custom_scripts(): array {
$schedule = vv_schedule_load();
$scripts = [];
$collect = function(string $dir, string $relPrefix) use ($schedule, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$rel = $relPrefix . basename($path);
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
};
$collect(SCRIPTS_DIR . '/Custom', 'Custom/');
// Platform adapter custom scripts (Plugin/<platform>/Custom/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Custom') ?: [] as $customDir) {
$platform = basename(dirname($customDir));
$collect($customDir, "Plugin/$platform/Custom/");
}
return $scripts;
}
// Load rsync standalone config (location + cron) for a flag name from schedule.json.
function vv_rsync_standalone(string $flagName): array {
$s = vv_schedule_load();
$r = $s['__rsync_' . $flagName] ?? [];
return [
'location' => (string)($r['location'] ?? ''),
'cron' => (string)($r['cron'] ?? ''),
];
}
// Extract *_SCRIPTS array variable names that an orchestrator iterates over.
function vv_orch_conf_arrays(string $orchPath): array {
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
return array_unique($refs[1] ?? []);
}
// Return .sh scripts that exist in SCRIPTS_DIR but are not referenced in any
// master.conf *_SCRIPTS array and are not shown in any other scheduler card.
function vv_script_library(): array {
$scriptsDir = SCRIPTS_DIR;
$confMap = vv_conf_script_map();
$orchIds = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $p) {
$orchIds[] = 'Orchestrators/' . basename($p);
}
// Scripts already shown in their own cards are not "unlisted"
$schedule = vv_schedule_load();
$cardIds = array_flip(array_merge(
array_column(vv_tools_scripts(), 'id'),
array_column(vv_custom_scripts(), 'id')
));
// UI-only subdirs under Plugin/<platform>/ — no runnable scripts
$pluginUiDirs = ['api', 'include', 'pages', 'css', 'js', 'icons', 'event'];
$exclude = ['.git', 'Orchestrators', 'Custom', 'Configurations'];
$library = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($scriptsDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
$base = rtrim($scriptsDir, '/') . '/';
foreach ($ri as $rf) {
if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
$rel = ltrim(str_replace($base, '', $rf->getPathname()), '/');
$parts = explode('/', $rel);
if (in_array($parts[0], $exclude)) continue;
if ($parts[0] === 'Plugin') {
// Require Plugin/<platform>/<category>/<script>.sh — skip root-level adapter files
if (count($parts) < 4) continue;
// Skip UI-only category dirs
if (in_array($parts[2], $pluginUiDirs)) continue;
} elseif (count($parts) < 2) {
continue;
}
if (in_array($rel, $orchIds) || isset($confMap[$rel]) || isset($cardIds[$rel]) || isset($schedule[$rel])) continue;
$library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
}
} catch (Exception $e) {}
usort($library, fn($a, $b) => strcmp($a['id'], $b['id']));
return $library;
}
// Load custom-script folder assignments from schedule.json (__folders key).
function vv_folders_load(): array {
$s = vv_schedule_load();
$f = $s['__folders'] ?? [];
return is_array($f) ? $f : [];
}
// Walk the scripts repo and return the job tree.
// Type is derived from the saved cron value: array_start / array_stop → 'event', else 'orchestrator'.
// Well-known event orchs get their cron seeded from $eventDefaults when not yet in schedule.json.
// Any orch or custom script can carry array_start / array_stop as its cron — the event scripts fire all of them.
function vv_job_tree(): array {
$scriptsDir = SCRIPTS_DIR;
$schedule = vv_schedule_load();
// First-run seeds only — applied when schedule.json has no entry for these IDs yet.
$eventDefaults = [
'Orchestrators/array_started.sh' => 'array_start',
'Orchestrators/array_stopping.sh' => 'array_stop',
];
$orchs = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $path) {
$id = 'Orchestrators/' . basename($path);
$default = $eventDefaults[$id] ?? '';
$entry = $schedule[$id] ?? ['enabled' => false, 'cron' => $default];
$cron = $entry['cron'] ?? $default;
$isEvent = str_starts_with($cron, 'array_');
$suggested = $isEvent ? ['cron' => '', 'label' => ''] : vv_script_suggested_cron($path);
$orchs[] = [
'id' => $id,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'type' => $isEvent ? 'event' : 'orchestrator',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $cron,
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
'children' => vv_script_children($path, $schedule),
'conf_arrays' => vv_orch_conf_arrays($path),
];
}
// Events first (array_start before array_stop), then alphabetical by label.
usort($orchs, function($a, $b) {
$ae = $a['type'] === 'event' ? 0 : 1;
$be = $b['type'] === 'event' ? 0 : 1;
if ($ae !== $be) return $ae - $be;
if ($ae === 0) {
$as = str_contains($a['cron'], 'start') ? 0 : 1;
$bs = str_contains($b['cron'], 'start') ? 0 : 1;
if ($as !== $bs) return $as - $bs;
}
return strcmp($a['label'], $b['label']);
});
return $orchs;
}
// Parse a bash array from master.conf content and return its script paths.
// Handles entries with inline args ("script.sh --flag") and skips commented lines (#"...").
function vv_parse_conf_array(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$scripts = [];
preg_match_all('/^\s*(?!#)"([^"]+)"/m', $m[1], $entries);
foreach ($entries[1] as $entry) {
$parts = preg_split('/\s+/', trim($entry));
$path = $parts[0] ?? '';
if (substr($path, -3) === '.sh') $scripts[] = $path;
}
return $scripts;
}
// Like vv_parse_conf_array but includes commented entries.
// Returns array of ['path' => string, 'enabled' => bool].
function vv_parse_conf_array_full(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$results = [];
foreach (explode("\n", $m[1]) as $line) {
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
$results[] = ['path' => $path, 'enabled' => !$commented];
}
return $results;
}
// Build a map of script rel-path → conf status by scanning all *_SCRIPTS arrays in master.conf.
// Cached per-request so multiple callers only read the file once.
function vv_conf_script_map(): array {
static $cache = null;
if ($cache !== null) return $cache;
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) return $cache = [];
$lines = file($confPath, FILE_IGNORE_NEW_LINES) ?: [];
$map = [];
$inArray = false;
$arrayVar = '';
foreach ($lines as $line) {
if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) { $inArray = true; $arrayVar = $am[1]; }
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
if (!isset($map[$path])) $map[$path] = ['array' => $arrayVar, 'enabled' => !$commented, 'managed' => true];
}
return $cache = $map;
}
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*(?:#.*)?$/m', $conf, $m)) {
return $m[1] === 'true';
}
return false;
}
// Write a boolean flag value to master.conf.
function vv_conf_flag_set(string $name, bool $value): bool {
$confPath = CONF_DIR . '/master.conf';
$content = file_get_contents($confPath);
if ($content === false) return false;
$val = $value ? 'true' : 'false';
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
if (!$count) return false;
return file_put_contents($confPath, $new) !== false;
}
// Comment or uncomment a script's line in the first master.conf array that contains it.
function vv_conf_toggle_script(string $rel, bool $enable): bool {
$confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) return false;
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
}
break;
}
unset($line);
if (!$changed) return true;
return file_put_contents($confPath, implode('', $lines)) !== false;
}
// Parse an orchestrator script to find which child scripts it calls.
// Two strategies, merged and deduped:
// 1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh
// 2. master.conf arrays: detects ${VARNAME[@]} iteration and reads the array from master.conf
// Root-level files (load_config.sh etc.) excluded — must be in a subdirectory.
// Each candidate validated against the filesystem.
function vv_script_children(string $orchPath, array $schedule): array {
$scriptsDir = SCRIPTS_DIR;
$content = file_get_contents($orchPath) ?: '';
$children = [];
$seen = [];
$confMap = vv_conf_script_map();
// Detect which tier rsync flag this orch controls (e.g. "INTERMEDIATE" → INTERMEDIATE_RSYNC_ENABLED)
$rsyncFlagName = null;
if (preg_match('/check_rsync_enabled\s+"([A-Z]+)"/', $content, $rm)) {
$rsyncFlagName = $rm[1] . '_RSYNC_ENABLED';
}
$addChild = function(string $rel) use ($scriptsDir, $schedule, $confMap, &$children, &$seen) {
if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return;
$seen[$rel] = true;
$entry = $schedule[$rel] ?? ['enabled' => false, 'cron' => ''];
$conf = $confMap[$rel] ?? ['array' => null, 'enabled' => null, 'managed' => false];
$suggested = vv_script_suggested_cron("$scriptsDir/$rel");
$children[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($rel, '.sh')),
'desc' => vv_script_description("$scriptsDir/$rel"),
'type' => 'script',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'conf_managed' => $conf['managed'],
'conf_enabled' => $conf['enabled'], // null if not in any *_SCRIPTS array
'conf_array' => $conf['array'],
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
];
};
// Strategy 1: static variable paths ($VAR/../Category/script.sh or $VAR/Category/script.sh)
preg_match_all(
'/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/',
$content, $m
);
foreach ($m[1] as $rel) $addChild($rel);
// Strategy 2: master.conf arrays — includes commented (disabled) entries so they appear in the UI
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
if (!empty($refs[1])) {
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (array_unique($refs[1]) as $varName) {
foreach (vv_parse_conf_array_full($confRaw, $varName) as $item) $addChild($item['path']);
}
}
// Annotate Rsync/rsync.sh as a conf_flag child if this orch controls a rsync tier flag
if ($rsyncFlagName) {
foreach ($children as &$c) {
if ($c['id'] === 'Rsync/rsync.sh') {
$c['type'] = 'conf_flag';
$c['flag_name'] = $rsyncFlagName;
$c['flag_value'] = vv_conf_flag_value($rsyncFlagName);
break;
}
}
unset($c);
}
return $children;
}
// Extract the comment header block from a bash script (shebang + all leading comment lines).
// Returns raw lines with # markers intact.
function vv_script_header(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 80);
$out = [];
foreach ($lines as $line) {
$t = rtrim($line);
if (str_starts_with($t, '#') || ($out === [] && str_starts_with($t, '#!'))) {
$out[] = $t;
} elseif ($t === '' && !empty($out)) {
$out[] = $t; // allow blank lines within header
} else {
break;
}
}
// Trim trailing blank lines
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Strip the leading # marker from each line of a script header for cleaner display.
// Also drops the shebang line (#!/bin/bash) since it's not informative in this context.
function vv_script_header_clean(string $path): string {
$raw = vv_script_header($path);
if (!$raw) return '';
$lines = explode("\n", $raw);
$out = [];
foreach ($lines as $line) {
if (str_starts_with($line, '#!')) continue; // shebang — not useful in header display
$out[] = preg_replace('/^#\s?/', '', $line); // strip # and optional space
}
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Read a named section from a markdown file.
// Calls $matcher(heading, isIntro) where isIntro=true for content before the first heading.
// Returns the first matching section body, capped at $maxChars.
function vv_readme_section(string $readmePath, callable $matcher, int $maxChars = 3000): string {
if (!file_exists($readmePath)) return '';
$content = file_get_contents($readmePath) ?: '';
$parts = preg_split('/^(#{1,4}[^\n]*)/m', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
$heading = '';
$isIntro = true;
foreach ($parts as $i => $part) {
if ($i % 2 === 1) {
$heading = trim(preg_replace('/^#{1,4}\s*/', '', $part));
$isIntro = false;
continue;
}
$body = trim($part);
if ($body === '') continue;
if ($matcher($heading, $isIntro)) {
return strlen($body) > $maxChars ? substr($body, 0, $maxChars) . "\n[…]" : $body;
}
}
return '';
}