$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[] = ""; // 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 . '/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_custom_scripts(): array { $dir = SCRIPTS_DIR . '/Custom'; $schedule = vv_schedule_load(); $scripts = []; foreach (glob("$dir/*.sh") ?: [] as $path) { $rel = 'Custom/' . basename($path); $entry = $schedule[$rel] ?? []; $scripts[] = [ 'id' => $rel, 'label' => basename($path, '.sh'), 'desc' => vv_script_description($path), 'enabled' => (bool)($entry['enabled'] ?? false), 'cron' => $entry['cron'] ?? '', 'log_enabled' => (bool)($entry['log_enabled'] ?? false), ]; } 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 orchestrators or custom scripts. 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); } $exclude = ['Plugin', '.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 (count($parts) < 2 || in_array($parts[0], $exclude)) continue; if (in_array($rel, $orchIds) || isset($confMap[$rel])) continue; $library[] = ['id' => $rel, '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: // hardcoded array-event entries first, then cron-scheduled orchestrators function vv_job_tree(): array { $scriptsDir = SCRIPTS_DIR; $schedule = vv_schedule_load(); // Hardcoded array-event entries — always present, trigger via Unraid event scripts $eventDefs = [ ['id' => 'Orchestrators/array_started.sh', 'cron' => '@array_start', 'label' => 'Array Starting'], ['id' => 'Orchestrators/array_stopping.sh', 'cron' => '@array_stop', 'label' => 'Array Stopping'], ]; $orchs = []; $eventIds = []; foreach ($eventDefs as $ev) { $id = $ev['id']; $path = "$scriptsDir/$id"; $entry = $schedule[$id] ?? []; $eventIds[] = $id; $orchs[] = [ 'id' => $id, 'label' => $ev['label'], 'desc' => vv_script_description($path), 'type' => 'event', 'enabled' => (bool)($entry['enabled'] ?? false), 'cron' => $ev['cron'], 'log_enabled' => (bool)($entry['log_enabled'] ?? false), 'suggested_cron' => '', 'suggested_label' => '', 'children' => vv_script_children($path, $schedule), ]; } // Cron-scheduled orchestrators — discovered by glob, event entries excluded $orchPattern = "$scriptsDir/Orchestrators/*.sh"; foreach (glob($orchPattern) ?: [] as $path) { $id = 'Orchestrators/' . basename($path); if (in_array($id, $eventIds, true)) continue; $entry = $schedule[$id] ?? ['enabled' => false, 'cron' => '']; $suggested = vv_script_suggested_cron($path); $orchs[] = [ 'id' => $id, 'label' => basename($path, '.sh'), 'desc' => vv_script_description($path), 'type' => 'orchestrator', 'enabled' => (bool)($entry['enabled'] ?? false), 'cron' => $entry['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), ]; } 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' => 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 ''; }