$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; // Custom Scripts live outside the repo (CUSTOM_SCRIPTS_DIR) — everything else resolves under SCRIPTS_DIR. $script = str_starts_with($id, 'Custom/') ? CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/')) : "$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']; // Scripts that live outside Tools/ but are run by hand often enough to belong on the card. // Named individually rather than by adopting their folder: System_Essentials also holds // conf_sync, rsync_stop and the rest of the shutdown chain, and putting those in front of the // operator as ordinary tools misrepresents what they are. // // server_reboot.sh stays where it is because it is not a standalone utility — it is the head // of the clean-shutdown sequence, calling array_stopping.sh, mover_stop.sh and // user_scripts_stop.sh in order, and the System_Essentials documentation describes it as that // chain. Moving the file to match the UI would break the grouping that explains it. $ADOPTED = ['System_Essentials/server_reboot.sh']; // The AI tools are adopted only where the index and the model actually live, and only while // the subsystem is switched on. Elsewhere they are two rows that can only ever fail — the // retrieval index is not synced between hosts, Ollama runs on one of them, and both scripts // refuse on their own unless AI_ENABLED is true. vv_ai_ui_on() is the same gate the AI tab // and the assistant dock use, so AI off means no AI anywhere in the UI, not most of it. if (vv_ai_ui_on()) { array_push($ADOPTED, 'AI/ai_index.sh', 'AI/ai_query.sh'); } $schedule = vv_schedule_load(); $scripts = []; $entryFor = function(string $path, string $rel) use ($schedule): array { $entry = $schedule[$rel] ?? []; return [ '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 = function(string $dir, string $relPrefix) use ($EXCLUDE, $entryFor, &$scripts): void { foreach (glob("$dir/*.sh") ?: [] as $path) { $base = basename($path); if (in_array($base, $EXCLUDE, true)) continue; $scripts[] = $entryFor($path, $relPrefix . $base); } }; // General tools $collect(SCRIPTS_DIR . '/Tools', 'Tools/'); // Platform adapter tools (Plugin//Tools/) foreach (glob(SCRIPTS_DIR . '/Plugin/*/Tools') ?: [] as $toolsDir) { $platform = basename(dirname($toolsDir)); $collect($toolsDir, "Plugin/$platform/Tools/"); } // Adopted individually. Skipped silently when absent so a host without the script — or a // sparse checkout that never pulled it — shows one fewer tool rather than a broken row. foreach ($ADOPTED as $rel) { $path = SCRIPTS_DIR . '/' . $rel; if (is_file($path)) $scripts[] = $entryFor($path, $rel); } usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label'])); return $scripts; } // Lists every Custom Script for the scheduler page. Discovery is glob-based, not a // registry — any *.sh file dropped directly into CUSTOM_SCRIPTS_DIR (or a platform // adapter's own Custom/ folder) shows up here, whether or not it was created via the // page's "+ Create Script" editor or has a schedule.json entry yet. 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(CUSTOM_SCRIPTS_DIR, 'Custom/'); // Platform adapter custom scripts (Plugin//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// — 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///