From 3ea80ef944fda0145e549a9c92c64bf4b654f4d4 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Tue, 4 Aug 2026 19:30:38 -0400 Subject: [PATCH] Single-source the scheduler help from markdown the AI index can read --- AI/ai_query.sh | 4 +- AI/lib/chunk.js | 8 +- Plugin/unraid/css/varaverk.css | 30 +++ Plugin/unraid/include/docs.php | 148 +++++++++++++-- Plugin/unraid/pages/ai.php | 1 + .../unraid/pages/readme/scheduler-readme.md | 171 ++++++++++++++++++ Plugin/unraid/pages/scheduler.php | 47 ++--- 7 files changed, 363 insertions(+), 46 deletions(-) create mode 100644 Plugin/unraid/pages/readme/scheduler-readme.md 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 .= "\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 .= ''; + $out .= "\n"; + $inTable = true; + } else { + $out .= ''; + foreach ($cells as $c) $out .= ''; + $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 .= '
    1. ' . $inline($m[1]) . "
    2. \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')) {
' . $inline($c) . '
' . $inline($c) . '