From 9eb29bb41e2cbb9e46f141a9987dacf1610ec6db Mon Sep 17 00:00:00 2001 From: meels Date: Tue, 28 Jul 2026 14:57:11 +0200 Subject: [PATCH] Add sync coverage button, preflight-disable buttons, fix output wrapping (I2, I3, I6) - I2: .wd-row now wraps and .wd-output takes the full row width, so streamed output no longer gets crushed beside the button on the filename's line. - I3: split .webinar-dash into token scope + a new .wd-bar toolbar above a .wd-grid two-pane layout. Extracted renderAll (called from onload) which renders a global "Sync coverage" button, generalised runIngest's subprocess handling into a shared runClaude keyed by run id, and added spawnGate to preflight-disable both the sync and per-file ingest buttons when claude can't be spawned (no desktop Obsidian / no child_process). A successful ingest or sync now re-renders the dashboard instead of requiring reopen. - I6: the right pane now shows the tracked script path and last-synced date from the coverage file's metadata, and flags when cfg.script disagrees with it. - Minors: documented the unescaped-pipe split regex, added role="img" to the coverage meter alongside its aria-label. --- .obsidian/plugins/webinar-dash/main.js | 201 ++++++++++++++-------- .obsidian/plugins/webinar-dash/styles.css | 42 ++++- 2 files changed, 167 insertions(+), 76 deletions(-) diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index 1a87929..d5f5464 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -94,6 +94,9 @@ function parseCoverageTable(markdown) { if (!inTable) return; const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); + // Split on unescaped pipes only. Obsidian escapes the pipe of a piped + // wikilink inside a table cell as `\|`, and a plain split("|") tears + // `[[harness\|alias]]` into two cells, shifting every later column left. const cells = body.split(/(? c.trim()); if (cells.length < 3) { @@ -220,7 +223,7 @@ function formatBytes(n) { return `${n.toLocaleString("en-US")} B`; } -function renderLeftPane(container, pipeline, onIngest) { +function renderLeftPane(container, pipeline, onIngest, gate) { const pane = container.createDiv({ cls: "wd-pane" }); const queue = pane.createDiv({ cls: "wd-block" }); @@ -239,7 +242,12 @@ function renderLeftPane(container, pipeline, onIngest) { const btn = row.createEl("button", { cls: "wd-btn" }); addIcon(btn, "terminal"); btn.createSpan({ text: "Ingest" }); - btn.addEventListener("click", () => onIngest(file, row)); + if (gate && !gate.ok) { + btn.disabled = true; + btn.setAttr("title", gate.reason); + } else { + btn.addEventListener("click", () => onIngest(file, row)); + } } const done = pane.createDiv({ cls: "wd-block" }); @@ -270,16 +278,29 @@ function renderLeftPane(container, pipeline, onIngest) { const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" }; -function renderRightPane(container, parsed, reconciled) { +function renderRightPane(container, parsed, reconciled, cfg) { const pane = container.createDiv({ cls: "wd-pane" }); pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" }); + const meta = parsed.meta || {}; + pane.createDiv({ + cls: "wd-mono", + text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`, + }); + if (cfg && cfg.script && meta.script && meta.script !== cfg.script) { + pane.createDiv({ + cls: "wd-error", + text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`, + }); + } + const counts = { covered: 0, partial: 0, absent: 0 }; for (const row of parsed.rows) counts[row.status] += 1; const total = parsed.rows.length; if (total > 0) { const meter = pane.createDiv({ cls: "wd-meter" }); + meter.setAttr("role", "img"); meter.setAttr( "aria-label", `Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}` @@ -357,21 +378,45 @@ class WebinarDashPlugin extends PluginBase { this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => { const cfg = parseConfig(source); const root = el.createDiv({ cls: "webinar-dash" }); - try { - const pipeline = await this.readPipeline(cfg); - renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl)); - } catch (err) { - root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` }); - } - try { - const { parsed, reconciled } = await this.readCoverage(cfg); - renderRightPane(root, parsed, reconciled); - } catch (err) { - root.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` }); - } + await this.renderAll(root, cfg); }); } + async renderAll(root, cfg) { + root.empty(); + const gate = this.spawnGate(); + const refresh = () => { this.renderAll(root, cfg); }; + + const bar = root.createDiv({ cls: "wd-bar" }); + const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" }); + addIcon(sync, "refresh"); + sync.createSpan({ text: "Sync coverage" }); + if (!gate.ok) { + sync.disabled = true; + sync.setAttr("title", gate.reason); + } else { + sync.addEventListener("click", () => + this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh) + ); + } + + const grid = root.createDiv({ cls: "wd-grid" }); + + try { + const pipeline = await this.readPipeline(cfg); + renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate); + } catch (err) { + grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` }); + } + + try { + const { parsed, reconciled } = await this.readCoverage(cfg); + renderRightPane(grid, parsed, reconciled, cfg); + } catch (err) { + grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` }); + } + } + async readCoverage(cfg) { const file = this.app.vault.getAbstractFileByPath(cfg.coverage); if (!file) throw new Error(`no coverage file at ${cfg.coverage}`); @@ -415,60 +460,55 @@ class WebinarDashPlugin extends PluginBase { return null; } - runIngest(file, rowEl) { + spawnGate() { + if (!this.vaultPath()) { + return { ok: false, reason: "Needs desktop Obsidian." }; + } + try { + require("child_process"); + } catch (_) { + return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." }; + } + return { ok: true, reason: "" }; + } + + // One subprocess runner for every button. `key` is what makes a run unique in + // `this.running` — a file path for ingest, a constant for sync — so the guard + // survives the row re-renders that a DOM-scoped guard could not. + runClaude(key, prompt, hostEl, label, onSuccess) { const Notice = OB ? OB.Notice : null; const notify = (msg) => { if (Notice) new Notice(msg); }; - // In-flight state is keyed on the file, not the row. Obsidian rebuilds the - // row on every re-render, so a row-scoped guard would let a re-render hand - // out a fresh row whose guard is unset — and a second click would then run - // a second unsupervised agent against the same file, concurrently writing - // the same wiki pages as the first. - if (this.running.has(file.path)) { - notify(`Already ingesting ${file.name}.`); + if (this.running.has(key)) { + notify(`Already running: ${label}.`); return; } - if (!isSafeFilename(file.name)) { - rowEl.createDiv({ - cls: "wd-output", - text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`, - }); + const gate = this.spawnGate(); + if (!gate.ok) { + notify(gate.reason); return; } - + const { spawn } = require("child_process"); const base = this.vaultPath(); - if (!base) { - notify("Ingest needs desktop Obsidian."); - return; - } - let spawn; - try { - ({ spawn } = require("child_process")); - } catch (_) { - notify("child_process unavailable — ingest needs desktop Obsidian."); - return; - } + // A retry reuses the same host element. Clear the previous run's status and + // output so they are replaced rather than stacked. + hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); - // A retry reuses the same row. Clear the previous run's status and output - // so they are replaced rather than stacked on top of each other. - rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); - - const button = rowEl.querySelector("button"); + const button = hostEl.querySelector("button"); if (button) button.disabled = true; - this.running.add(file.path); + this.running.add(key); - const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); - const output = rowEl.createDiv({ cls: "wd-output", text: "" }); + const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); + const output = hostEl.createDiv({ cls: "wd-output", text: "" }); const started = Date.now(); const timer = window.setInterval(() => { status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); }, 1000); this.registerInterval(timer); - // Bounded as it accumulates, not only when displayed, so a long or noisy - // run cannot grow this string without limit. + // Bounded as it accumulates, not only when displayed. let buffered = ""; const append = (text) => { buffered = (buffered + text).slice(-8000); @@ -476,37 +516,29 @@ class WebinarDashPlugin extends PluginBase { output.scrollTop = output.scrollHeight; }; - // Single exit path: every way this run can end clears the timer, releases - // the file, and re-enables the button. - const finish = (cls, label) => { + // Node can emit both `error` and `close` for one failure. `settled` keeps the + // first, more specific message instead of letting `exit null` overwrite it. + let settled = false; + const finish = (cls, text) => { + if (settled) return; + settled = true; window.clearInterval(timer); - this.running.delete(file.path); + this.running.delete(key); status.className = `wd-status ${cls}`; - status.setText(label); + status.setText(text); if (button) button.disabled = false; }; let child; try { - // `claude` resolves to a real .exe here, so libuv finds it via PATH and - // PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell, - // Node concatenates argv without quoting and cmd.exe re-tokenizes, so the - // prompt arrives as two arguments and the filename is silently discarded. - // Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the - // program sees only "ingest". If a .cmd shim ever needs supporting, use the - // single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }). - child = spawn("claude", ["-p", `ingest "${file.name}"`], { cwd: base }); + // See the C1 comment in runIngest: no shell, deliberately. + child = spawn("claude", ["-p", prompt], { cwd: base }); } catch (err) { - // spawn() throws synchronously for some argument shapes. Without this the - // timer would run forever and the row would stay disabled until reload. append(`\nCould not start claude: ${err.message}`); finish("wd-status-failed", "failed"); return; } - // Decode as UTF-8 across chunk boundaries. Raw Buffer chunks split wherever - // the OS buffer ends, and this vault's output is full of Cyrillic and em - // dashes that would otherwise decode as replacement characters. child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", append); @@ -521,12 +553,43 @@ class WebinarDashPlugin extends PluginBase { const secs = Math.round((Date.now() - started) / 1000); if (code === 0) { finish("wd-status-done", `done in ${secs}s`); - notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); + if (onSuccess) onSuccess(); } else { finish("wd-status-failed", `failed - exit ${code}`); } }); } + + runIngest(file, rowEl, onSuccess) { + const Notice = OB ? OB.Notice : null; + + if (!isSafeFilename(file.name)) { + rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); + rowEl.createDiv({ + cls: "wd-output", + text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`, + }); + return; + } + + // `claude` resolves to a real .exe here, so libuv finds it via PATH and + // PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell, + // Node concatenates argv without quoting and cmd.exe re-tokenizes, so the + // prompt arrives as two arguments and the filename is silently discarded. + // Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the + // program sees only "ingest". If a .cmd shim ever needs supporting, use the + // single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }). + this.runClaude( + file.path, + `ingest "${file.name}"`, + rowEl, + file.name, + () => { + if (Notice) new Notice(`Ingested ${file.name}.`); + if (onSuccess) onSuccess(); + } + ); + } } module.exports = WebinarDashPlugin; diff --git a/.obsidian/plugins/webinar-dash/styles.css b/.obsidian/plugins/webinar-dash/styles.css index 6026fe4..4a0da09 100644 --- a/.obsidian/plugins/webinar-dash/styles.css +++ b/.obsidian/plugins/webinar-dash/styles.css @@ -25,9 +25,9 @@ font-family: var(--wd-sans); color: var(--wd-fg); - display: grid; - grid-template-columns: minmax(0, 38fr) minmax(0, 62fr); - gap: 24px; + display: flex; + flex-direction: column; + gap: 16px; } .theme-dark .webinar-dash { @@ -55,11 +55,37 @@ --wd-danger: #ff5c50; } -@media (max-width: 820px) { - .webinar-dash { grid-template-columns: minmax(0, 1fr); } +.wd-grid { + display: grid; + grid-template-columns: minmax(0, 38fr) minmax(0, 62fr); + gap: 24px; +} +.wd-grid > * { min-width: 0; } + +.wd-bar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + border: 1px solid var(--wd-border); + border-radius: 4px; + padding: 8px 12px; + background: var(--wd-bg-subtle); } -.webinar-dash > * { min-width: 0; } +.wd-btn-ghost { + background: transparent; + color: var(--wd-fg); + border-color: var(--wd-border-strong); +} +.wd-btn-ghost:hover { + background: var(--wd-bg-muted, var(--wd-bg-subtle)); + border-color: var(--wd-border-strong); +} + +@media (max-width: 820px) { + .wd-grid { grid-template-columns: minmax(0, 1fr); } +} .wd-pane { display: flex; flex-direction: column; gap: 20px; } @@ -76,7 +102,7 @@ .wd-block { display: flex; flex-direction: column; gap: 12px; } .wd-row { - display: flex; align-items: center; gap: 12px; + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border: 1px solid var(--wd-border); border-radius: 4px; padding: 12px 12px 12px 16px; background: var(--wd-bg); } @@ -166,6 +192,8 @@ .wd-status-done { color: var(--wd-ok); } .wd-status-failed { color: var(--wd-danger); } .wd-output { + flex-basis: 100%; + width: 100%; font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap; max-height: 220px; overflow: auto; margin-top: 8px; border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;