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.
This commit is contained in:
meels
2026-07-28 14:57:11 +02:00
parent 3bc1afa9f1
commit 9eb29bb41e
2 changed files with 167 additions and 76 deletions

View File

@@ -94,6 +94,9 @@ function parseCoverageTable(markdown) {
if (!inTable) return; if (!inTable) return;
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); 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(/(?<!\\)\|/).map((c) => c.trim()); const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
if (cells.length < 3) { if (cells.length < 3) {
@@ -220,7 +223,7 @@ function formatBytes(n) {
return `${n.toLocaleString("en-US")} B`; 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 pane = container.createDiv({ cls: "wd-pane" });
const queue = pane.createDiv({ cls: "wd-block" }); const queue = pane.createDiv({ cls: "wd-block" });
@@ -239,7 +242,12 @@ function renderLeftPane(container, pipeline, onIngest) {
const btn = row.createEl("button", { cls: "wd-btn" }); const btn = row.createEl("button", { cls: "wd-btn" });
addIcon(btn, "terminal"); addIcon(btn, "terminal");
btn.createSpan({ text: "Ingest" }); 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" }); 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" }; 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" }); const pane = container.createDiv({ cls: "wd-pane" });
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" }); 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 }; const counts = { covered: 0, partial: 0, absent: 0 };
for (const row of parsed.rows) counts[row.status] += 1; for (const row of parsed.rows) counts[row.status] += 1;
const total = parsed.rows.length; const total = parsed.rows.length;
if (total > 0) { if (total > 0) {
const meter = pane.createDiv({ cls: "wd-meter" }); const meter = pane.createDiv({ cls: "wd-meter" });
meter.setAttr("role", "img");
meter.setAttr( meter.setAttr(
"aria-label", "aria-label",
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}` `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) => { this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
const cfg = parseConfig(source); const cfg = parseConfig(source);
const root = el.createDiv({ cls: "webinar-dash" }); const root = el.createDiv({ cls: "webinar-dash" });
try { await this.renderAll(root, cfg);
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}` });
}
}); });
} }
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) { async readCoverage(cfg) {
const file = this.app.vault.getAbstractFileByPath(cfg.coverage); const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`); if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
@@ -415,60 +460,55 @@ class WebinarDashPlugin extends PluginBase {
return null; 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 Notice = OB ? OB.Notice : null;
const notify = (msg) => { if (Notice) new Notice(msg); }; const notify = (msg) => { if (Notice) new Notice(msg); };
// In-flight state is keyed on the file, not the row. Obsidian rebuilds the if (this.running.has(key)) {
// row on every re-render, so a row-scoped guard would let a re-render hand notify(`Already running: ${label}.`);
// 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}.`);
return; return;
} }
if (!isSafeFilename(file.name)) { const gate = this.spawnGate();
rowEl.createDiv({ if (!gate.ok) {
cls: "wd-output", notify(gate.reason);
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
});
return; return;
} }
const { spawn } = require("child_process");
const base = this.vaultPath(); const base = this.vaultPath();
if (!base) {
notify("Ingest needs desktop Obsidian.");
return;
}
let spawn; // A retry reuses the same host element. Clear the previous run's status and
try { // output so they are replaced rather than stacked.
({ spawn } = require("child_process")); hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
} catch (_) {
notify("child_process unavailable — ingest needs desktop Obsidian.");
return;
}
// A retry reuses the same row. Clear the previous run's status and output const button = hostEl.querySelector("button");
// 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");
if (button) button.disabled = true; 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 status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
const output = rowEl.createDiv({ cls: "wd-output", text: "" }); const output = hostEl.createDiv({ cls: "wd-output", text: "" });
const started = Date.now(); const started = Date.now();
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
}, 1000); }, 1000);
this.registerInterval(timer); this.registerInterval(timer);
// Bounded as it accumulates, not only when displayed, so a long or noisy // Bounded as it accumulates, not only when displayed.
// run cannot grow this string without limit.
let buffered = ""; let buffered = "";
const append = (text) => { const append = (text) => {
buffered = (buffered + text).slice(-8000); buffered = (buffered + text).slice(-8000);
@@ -476,37 +516,29 @@ class WebinarDashPlugin extends PluginBase {
output.scrollTop = output.scrollHeight; output.scrollTop = output.scrollHeight;
}; };
// Single exit path: every way this run can end clears the timer, releases // Node can emit both `error` and `close` for one failure. `settled` keeps the
// the file, and re-enables the button. // first, more specific message instead of letting `exit null` overwrite it.
const finish = (cls, label) => { let settled = false;
const finish = (cls, text) => {
if (settled) return;
settled = true;
window.clearInterval(timer); window.clearInterval(timer);
this.running.delete(file.path); this.running.delete(key);
status.className = `wd-status ${cls}`; status.className = `wd-status ${cls}`;
status.setText(label); status.setText(text);
if (button) button.disabled = false; if (button) button.disabled = false;
}; };
let child; let child;
try { try {
// `claude` resolves to a real .exe here, so libuv finds it via PATH and // See the C1 comment in runIngest: no shell, deliberately.
// PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell, child = spawn("claude", ["-p", prompt], { cwd: base });
// 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 });
} catch (err) { } 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}`); append(`\nCould not start claude: ${err.message}`);
finish("wd-status-failed", "failed"); finish("wd-status-failed", "failed");
return; 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.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8"); child.stderr.setEncoding("utf8");
child.stdout.on("data", append); child.stdout.on("data", append);
@@ -521,12 +553,43 @@ class WebinarDashPlugin extends PluginBase {
const secs = Math.round((Date.now() - started) / 1000); const secs = Math.round((Date.now() - started) / 1000);
if (code === 0) { if (code === 0) {
finish("wd-status-done", `done in ${secs}s`); finish("wd-status-done", `done in ${secs}s`);
notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); if (onSuccess) onSuccess();
} else { } else {
finish("wd-status-failed", `failed - exit ${code}`); 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; module.exports = WebinarDashPlugin;

View File

@@ -25,9 +25,9 @@
font-family: var(--wd-sans); font-family: var(--wd-sans);
color: var(--wd-fg); color: var(--wd-fg);
display: grid; display: flex;
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr); flex-direction: column;
gap: 24px; gap: 16px;
} }
.theme-dark .webinar-dash { .theme-dark .webinar-dash {
@@ -55,11 +55,37 @@
--wd-danger: #ff5c50; --wd-danger: #ff5c50;
} }
@media (max-width: 820px) { .wd-grid {
.webinar-dash { grid-template-columns: minmax(0, 1fr); } 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; } .wd-pane { display: flex; flex-direction: column; gap: 20px; }
@@ -76,7 +102,7 @@
.wd-block { display: flex; flex-direction: column; gap: 12px; } .wd-block { display: flex; flex-direction: column; gap: 12px; }
.wd-row { .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; border: 1px solid var(--wd-border); border-radius: 4px;
padding: 12px 12px 12px 16px; background: var(--wd-bg); padding: 12px 12px 12px 16px; background: var(--wd-bg);
} }
@@ -166,6 +192,8 @@
.wd-status-done { color: var(--wd-ok); } .wd-status-done { color: var(--wd-ok); }
.wd-status-failed { color: var(--wd-danger); } .wd-status-failed { color: var(--wd-danger); }
.wd-output { .wd-output {
flex-basis: 100%;
width: 100%;
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap; font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
max-height: 220px; overflow: auto; margin-top: 8px; max-height: 220px; overflow: auto; margin-top: 8px;
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px; border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;