diff --git a/AI/ai_query.sh b/AI/ai_query.sh index 9d5514a..9048b35 100755 --- a/AI/ai_query.sh +++ b/AI/ai_query.sh @@ -145,8 +145,8 @@ done # misleading failure this tool can produce. if [[ -n "$KIND" ]]; then case "$KIND" in - header|readme|manual|template|doc) ;; - *) echo "Unknown --kind=$KIND (expected: header, readme, manual, template, doc)" >&2 + header|readme|manual|template|doc|ui) ;; + *) echo "Unknown --kind=$KIND (expected: header, readme, manual, template, doc, ui)" >&2 exit 1 ;; esac fi diff --git a/AI/lib/chunk.js b/AI/lib/chunk.js index 0de7aa1..a32982f 100644 --- a/AI/lib/chunk.js +++ b/AI/lib/chunk.js @@ -213,6 +213,12 @@ function capSize(chunks) { function classify(rel) { const base = path.basename(rel); if (rel.startsWith('Deployment/') && rel.endsWith('.template')) return 'template'; + // WebGUI page docs, written for whoever is using the tab rather than maintaining it. Their + // own kind because every other kind here answers a maintainer's question: an operator asking + // "how do I stop this" needs the click path, and a corpus that is three-quarters script + // headers will otherwise always answer in conf edits. Matched on the folder, not the + // filename, so these can be named whatever reads best. + if (rel.startsWith('Plugin/unraid/pages/readme/') && base.endsWith('.md')) return 'ui'; if (base.endsWith('.md')) { if (base.startsWith('Manual')) return 'manual'; if (base.startsWith('README') || base === 'README.md') return 'readme'; @@ -237,7 +243,7 @@ function chunkFile(absPath, rel) { } else if (kind === 'template') { raw = sectionsFromConfTemplate(text) .map(s => ({ section: null, heading: s.heading, content: s.content })); - } else if (kind === 'readme' || kind === 'manual' || kind === 'doc') { + } else if (kind === 'readme' || kind === 'manual' || kind === 'doc' || kind === 'ui') { raw = sectionsFromMarkdown(text) .map(s => ({ section: null, heading: s.heading, content: s.content })); } diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 84fe454..3681a7e 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -503,6 +503,36 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r .vv-info-cols li strong { color: #ccc; } .vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px; font-size: 11px; color: #9ab; border: 1px solid #333; } +/* ── Rendered page docs (include/docs.php → pages/readme/*.md) ─────────────────────────────── */ +/* Two columns so the reference tables and the task sections sit side by side rather than making + the panel a long scroll. Headings span both, which is what keeps a task and its steps together + instead of splitting across the gap. */ +.vv-doc { columns: 320px 2; column-gap: 26px; font-size: 12px; color: #aaa; line-height: 1.55; } +.vv-doc > h1 { display: none; } /* the panel already has its own title */ +.vv-doc h2 { column-span: all; margin: 12px -4px 7px; padding: 4px 8px; background: #161a1d; + border-left: 2px solid #1e6fa5; font-size: 10px; font-weight: bold; + text-transform: uppercase; letter-spacing: .08em; color: #4a8ab5; } +.vv-doc h2:first-child { margin-top: 2px; } +.vv-doc p { margin: 0 0 7px; break-inside: avoid; } +.vv-doc ul, .vv-doc ol { margin: 0 0 8px; padding-left: 16px; } +.vv-doc li { margin-bottom: 4px; break-inside: avoid; } +.vv-doc strong { color: #ccc; } +.vv-doc em { color: #9a9a9a; font-style: italic; } +.vv-doc hr { display: none; } /* h2 bars already separate the sections */ +.vv-doc code { background: #1a1a1a; padding: 0 4px; border-radius: 2px; font-size: 11px; + color: #9ab; border: 1px solid #333; } +.vv-doc blockquote { margin: 0 0 8px; padding: 6px 9px; background: #16130d; + border-left: 2px solid #7a5a2a; color: #b39a72; break-inside: avoid; } +.vv-doc .vv-live-var { color: #8fc98f; border-color: #2e4a2e; } +.vv-doc .vv-unknown-var { color: #e57; border-color: #5a2a2a; } +.vv-doc-table { width: 100%; border-collapse: collapse; margin: 0 0 9px; break-inside: avoid; } +.vv-doc-table th { text-align: left; font-size: 9px; text-transform: uppercase; letter-spacing: .07em; + color: #4a4a4a; border-bottom: 1px solid #2a2a2a; padding: 3px 6px 3px 0; } +.vv-doc-table td { font-size: 11px; color: #999; border-bottom: 1px solid #1a1a1a; + padding: 4px 6px 4px 0; vertical-align: top; } +.vv-doc-code { background: #0d0d0d; border: 1px solid #222; border-radius: 3px; padding: 7px 9px; + font-size: 11px; color: #9ab; overflow-x: auto; break-inside: avoid; } + .vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444; padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; } diff --git a/Plugin/unraid/include/docs.php b/Plugin/unraid/include/docs.php index 959c20e..3be6eb6 100644 --- a/Plugin/unraid/include/docs.php +++ b/Plugin/unraid/include/docs.php @@ -83,17 +83,26 @@ function vv_docs_render(string $rel, array $vars): string { if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') return '
File not found.
'; if (!is_file($path)) return 'File not found.
'; - $md = file_get_contents($path); + return vv_docs_markdown(file_get_contents($path), $vars); +} - // Substitute `$VAR_NAME` markers with live conf values - $md = preg_replace_callback('/`\$([A-Z0-9_]+)`/', function($m) use ($vars) { - $key = $m[1]; - return isset($vars[$key]) - ? '' . htmlspecialchars($vars[$key]) . ''
- : '$' . htmlspecialchars($key) . '';
- }, $md);
-
- // Render markdown
+// Markdown → HTML for the subset these docs actually use: headings, paragraphs, bullet and
+// ordered lists, tables, blockquotes, fenced code, horizontal rules, and inline bold / italic /
+// code / links.
+//
+// Hand-rolled rather than vendored. Parsedown was the original plan and PARSEDOWN_PATH is still
+// honoured below if the file ever appears, but it has never been present on this system, so the
+// only path this function ever took was a dump of raw markdown — which is not a document,
+// it is the source of one. A renderer for a subset we control is a few dozen lines and adds
+// nothing to a repo that gets pushed.
+//
+// Escape first, then format. Every line is passed through htmlspecialchars() before any tag is
+// introduced, so the only HTML in the output is HTML this function put there. That is what makes
+// safe mode unnecessary rather than merely configured — and it is why the $VAR substitution runs
+// here, after escaping. Injecting into the markdown before rendering, as this file used to
+// do, meant both Parsedown's safe mode and the fallback escaped the tags and printed them
+// as literal text. The feature never worked; nothing called it, so nothing reported it.
+function vv_docs_markdown(string $md, array $vars): string {
if (file_exists(PARSEDOWN_PATH)) {
require_once PARSEDOWN_PATH;
$pd = new Parsedown();
@@ -101,6 +110,121 @@ function vv_docs_render(string $rel, array $vars): string {
return $pd->text($md);
}
- // Fallback: plain preformatted text
- return '' . htmlspecialchars($md) . '
';
+ $inline = function (string $s) use ($vars): string {
+ $s = htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+ // `$VAR` → the live conf value. Unresolved names render in their own class rather than
+ // silently reading as prose, so a stale reference in a doc is visible as a defect.
+ $s = preg_replace_callback('/`\$([A-Z0-9_]+)`/', function ($m) use ($vars) {
+ return isset($vars[$m[1]])
+ ? '' . htmlspecialchars($vars[$m[1]]) . ''
+ : '$' . $m[1] . '';
+ }, $s);
+ $s = preg_replace('/`([^`]+)`/', '$1', $s);
+ $s = preg_replace('/\*\*([^*]+)\*\*/', '$1', $s);
+ $s = preg_replace('/(?$1', $s);
+ // Links are restricted to http/https and relative paths — a doc is trusted, but these
+ // files sync between hosts, so javascript: must not be reachable through one.
+ $s = preg_replace('/\[([^\]]+)\]\((https?:\/\/[^\s)]+|[^\s):]+)\)/', '$1', $s);
+ return $s;
+ };
+
+ $out = '';
+ $list = null; // 'ul' | 'ol' | null
+ $inTable = false;
+ $inCode = false;
+ $para = [];
+ $quote = [];
+
+ $flushPara = function () use (&$para, &$out, $inline) {
+ if (!$para) return;
+ $out .= '' . $inline(implode(' ', $para)) . "
\n";
+ $para = [];
+ };
+ $flushQuote = function () use (&$quote, &$out, $inline) {
+ if (!$quote) return;
+ $out .= '' . $inline(implode(' ', $quote)) . "
\n";
+ $quote = [];
+ };
+ $closeList = function () use (&$list, &$out) {
+ if ($list) { $out .= "$list>\n"; $list = null; }
+ };
+ $closeTable = function () use (&$inTable, &$out) {
+ if ($inTable) { $out .= "\n"; $inTable = false; }
+ };
+
+ foreach (explode("\n", str_replace("\r\n", "\n", $md)) as $line) {
+ if (preg_match('/^```/', $line)) {
+ $flushPara(); $closeList(); $closeTable();
+ $out .= $inCode ? "\n" : '';
+ $inCode = !$inCode;
+ continue;
+ }
+ if ($inCode) { $out .= htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . "\n"; continue; }
+
+ $t = trim($line);
+
+ // One guard rather than a flush in every branch: the quote ends the moment a line is
+ // not a quote line, whatever that next line turns out to be.
+ if ($quote && !str_starts_with($t, '>')) $flushQuote();
+
+ if ($t === '') { $flushPara(); $closeList(); $closeTable(); continue; }
+ if (preg_match('/^(---+|\*\*\*+)$/', $t)) { $flushPara(); $closeList(); $closeTable(); $out .= "
\n"; continue; }
+
+ if (preg_match('/^(#{1,6})\s+(.*)$/', $t, $m)) {
+ $flushPara(); $closeList(); $closeTable();
+ $n = strlen($m[1]);
+ $out .= "" . $inline($m[2]) . " \n";
+ continue;
+ }
+
+ // Consecutive > lines are one quote, not one per line — same wrapping convention as
+ // paragraphs, which is how they are written.
+ if (preg_match('/^>\s?(.*)$/', $t, $m)) {
+ $closeList(); $closeTable();
+ if (!$quote) $flushPara();
+ $quote[] = $m[1];
+ continue;
+ }
+
+ // Tables: a header row, a separator of dashes, then body rows. The separator is what
+ // identifies the block — a lone pipe in prose is not a table.
+ if (strpos($t, '|') !== false && preg_match('/^\|?[\s:-]*-[\s|:-]*\|/', $t)) {
+ continue; // separator consumed by the header below
+ }
+ if (strpos($t, '|') !== false && substr_count($t, '|') >= 2) {
+ $cells = array_map('trim', explode('|', trim($t, '| ')));
+ if (!$inTable) {
+ $flushPara(); $closeList();
+ $out .= '';
+ foreach ($cells as $c) $out .= '' . $inline($c) . ' ';
+ $out .= " \n";
+ $inTable = true;
+ } else {
+ $out .= '';
+ foreach ($cells as $c) $out .= '' . $inline($c) . ' ';
+ $out .= " \n";
+ }
+ continue;
+ }
+ $closeTable();
+
+ if (preg_match('/^[-*]\s+(.*)$/', $t, $m)) {
+ $flushPara();
+ if ($list !== 'ul') { $closeList(); $out .= "\n"; $list = 'ul'; }
+ $out .= '- ' . $inline($m[1]) . "
\n";
+ continue;
+ }
+ if (preg_match('/^\d+\.\s+(.*)$/', $t, $m)) {
+ $flushPara();
+ if ($list !== 'ol') { $closeList(); $out .= "\n"; $list = 'ol'; }
+ $out .= '- ' . $inline($m[1]) . "
\n";
+ continue;
+ }
+
+ $para[] = $t;
+ }
+
+ $flushQuote(); $flushPara(); $closeList(); $closeTable();
+ if ($inCode) $out .= "\n";
+ return $out;
}
diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php
index 6724302..6bc84dc 100644
--- a/Plugin/unraid/pages/ai.php
+++ b/Plugin/unraid/pages/ai.php
@@ -307,6 +307,7 @@ if (is_dir('/var/log/varaverk')) {
+
-
- - Toggle — saves immediately to schedule.json
- - Cron field — auto-saved on focus-out; hint shows plain English; red border = invalid
- - ⚙ Cog — opens settings panel: script header + matching README/Manual sections + config fields
- - Script name — click navigates to the matching entry in the Orch tree (right panel)
- - Run — fires script immediately; resets the cron timer so next fire is one full interval later
- - Dry Run — same as Run but passes
--dry-run; no changes written to disk
- - Log — opens script log here; auto-scrolls; use Search box to filter/highlight lines
- - Stop — SIGTERM → 3 s → SIGKILL; clears stuck lock files
- - Verbose — appends
--log flag for per-item detail output
- - Cancel — discards changes and returns here; file is never touched until Save
- - ⚡ Array events — set any cron field to
array_start or array_stop; badge appears; fires on Unraid array start/stop; works on any orch or custom script
- - Arrange — drag scripts between orchs; drop to right panel removes from orch; Save Arrangement commits to master.conf
- - + Folder — collapsible subfolder in Custom Scripts; drag scripts in
- - Suggested cron — click the cron code badge in the Orch tree to apply and save instantly
- - Status dot (tree) — green/orange/red ● = last run ok/warn/error; dim = never run; hover for detail
- - Cron Calculator — type an expression or plain English; shows description + next 5 fires; Apply pushes to last-focused cron field
- - Recent Activity — last 24 runs; click a row to open that log; auto-expands on errors
- - Recent Errors — last error per script (7 days); click to open log; auto-expands on new errors
- - Advanced — top-right button, turns blue; ⚙ shows enriched view (header + docs + config); raw conf editing unlocked
- - Log search — filter box in toolbar when log is open; highlights matches, dims others
- - ← Scheduler Info — returns here from any log, editor, conf, or script view
- - Orchestrator & Child Behaviour
- - Orch ON — sole trigger; cron fires the orch; it calls children in sequence
- - Orch OFF — never runs automatically; children suppressed; each can get its own standalone cron
- - Child (orch ON) — toggle comments/uncomments the script in master.conf
- - Child (orch ON, no array) — orch hardcodes the call; toggle is display-only
- - Child (orch OFF) — enter a cron to run it standalone
- - Rsync badge — toggle writes TIER_RSYNC_ENABLED directly to master.conf
- - Rsync (orch OFF) — fill location + standalone cron + Save; both required for independent firing
-
+
+ = vv_docs_render('Plugin/unraid/pages/readme/scheduler-readme.md', $_vv_doc_vars) ?>
+