spawn(..., { shell: true }) made Node concatenate argv without quoting;
cmd.exe re-tokenized it and the ingest prompt arrived as a bare "ingest"
with the filename silently discarded, while the run still reported
success. claude.exe is a real executable here, so no shell is needed:
drop shell: true and pass argv directly. isSafeFilename is unchanged
(still correct defence in depth) but its comment now reflects that the
spawn path carries no injection surface today.
539 lines
20 KiB
JavaScript
539 lines
20 KiB
JavaScript
"use strict";
|
|
|
|
// Obsidian injects its own module resolver. Under plain `node --test` it is
|
|
// absent, so guard the require and fall back to an empty base class. This is
|
|
// what keeps the pure helpers below unit-testable without an Obsidian runtime.
|
|
let OB = null;
|
|
try {
|
|
OB = require("obsidian");
|
|
} catch (_) {
|
|
OB = null;
|
|
}
|
|
const PluginBase = OB ? OB.Plugin : class {};
|
|
|
|
const STATIONS = ["Chat box", "ReAct", "Tools", "Memory", "Skills", "Process", "OS"];
|
|
|
|
const DEFAULTS = {
|
|
script: "raw/sources/Webinar script.md",
|
|
coverage: "wiki/script-coverage.md",
|
|
rawDir: "raw/sources",
|
|
wikiSourceDir: "wiki/sources",
|
|
conceptDir: "wiki/concepts",
|
|
};
|
|
|
|
const RAW_PATH_RE = /^\s*-\s*\*\*Raw path:\*\*\s*`([^`]+)`/m;
|
|
|
|
function extractRawPath(text) {
|
|
const m = String(text).match(RAW_PATH_RE);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
function derivePipeline({ rawFiles, sourcePages }) {
|
|
// First claim on a raw path wins. A later page claiming the same file is a
|
|
// duplicate claim — real catalog drift — and joins `orphaned` rather than
|
|
// being silently dropped. `orphaned` therefore means "source page not paired
|
|
// with a raw file", whatever the reason.
|
|
const claimed = new Map();
|
|
const duplicates = [];
|
|
for (const page of sourcePages) {
|
|
if (!page.rawPath) continue;
|
|
if (claimed.has(page.rawPath)) duplicates.push(page);
|
|
else claimed.set(page.rawPath, page);
|
|
}
|
|
|
|
const processed = [];
|
|
const unprocessed = [];
|
|
for (const file of rawFiles) {
|
|
const page = claimed.get(file.path);
|
|
if (page) processed.push(Object.assign({}, file, { page }));
|
|
else unprocessed.push(file);
|
|
}
|
|
|
|
// One pass over sourcePages, so a page appears in `orphaned` at most once no
|
|
// matter how many of the three reasons apply to it. Concatenating a separate
|
|
// duplicates array here would double-count a losing claimant whose shared raw
|
|
// path is also missing from disk.
|
|
const rawPaths = new Set(rawFiles.map((f) => f.path));
|
|
const duplicateSet = new Set(duplicates);
|
|
const orphaned = sourcePages.filter(
|
|
(p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath)
|
|
);
|
|
|
|
unprocessed.sort((a, b) => a.name.localeCompare(b.name));
|
|
processed.sort((a, b) => b.page.name.localeCompare(a.page.name));
|
|
|
|
return { processed, unprocessed, orphaned };
|
|
}
|
|
|
|
const VALID_STATUS = new Set(["covered", "partial", "absent"]);
|
|
const SEPARATOR_RE = /^\|?\s*:?-{2,}/;
|
|
|
|
function parseCoverageTable(markdown) {
|
|
const text = String(markdown);
|
|
const meta = { script: null, lastSynced: null };
|
|
|
|
const scriptM = text.match(/^\s*-\s*\*\*Script:\*\*\s*`([^`]+)`/m);
|
|
if (scriptM) meta.script = scriptM[1].trim();
|
|
const syncM = text.match(/^\s*-\s*\*\*Last synced:\*\*\s*(\S+)/m);
|
|
if (syncM) meta.lastSynced = syncM[1].trim();
|
|
|
|
const rows = [];
|
|
const errors = [];
|
|
let inTable = false;
|
|
|
|
text.split(/\r?\n/).forEach((line, i) => {
|
|
const t = line.trim();
|
|
if (!t.startsWith("|")) {
|
|
inTable = false;
|
|
return;
|
|
}
|
|
if (SEPARATOR_RE.test(t)) {
|
|
inTable = true;
|
|
return;
|
|
}
|
|
if (!inTable) return;
|
|
|
|
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
|
|
const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
|
|
|
|
if (cells.length < 3) {
|
|
errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" });
|
|
return;
|
|
}
|
|
|
|
const status = cells[1].toLowerCase();
|
|
if (!VALID_STATUS.has(status)) {
|
|
errors.push({ line: i + 1, text: t, reason: `invalid status "${cells[1]}"` });
|
|
return;
|
|
}
|
|
|
|
// Capture up to the first ] | or backslash. Obsidian escapes the pipe in a
|
|
// piped wikilink inside a table cell, so the raw cell reads [[name\|alias]] —
|
|
// excluding the backslash is what keeps the trailing "\" out of the name.
|
|
const linkM = cells[0].match(/\[\[([^\]|\\]+)/);
|
|
const stationCell = cells[2];
|
|
|
|
rows.push({
|
|
concept: linkM ? linkM[1].trim() : cells[0],
|
|
status,
|
|
stations:
|
|
stationCell === "—" || stationCell === "-" || stationCell === ""
|
|
? []
|
|
: stationCell.split(",").map((s) => s.trim()).filter(Boolean),
|
|
pinned: (cells[3] || "").toLowerCase() === "yes",
|
|
line: i + 1,
|
|
});
|
|
});
|
|
|
|
return { meta, rows, errors };
|
|
}
|
|
|
|
function groupByStation(rows) {
|
|
const order = ["All stations", ...STATIONS, "No station"];
|
|
const buckets = new Map(order.map((k) => [k, []]));
|
|
|
|
for (const row of rows) {
|
|
let key;
|
|
if (row.stations.includes("all")) key = "All stations";
|
|
else if (row.stations.length === 0) key = "No station";
|
|
else key = row.stations[0];
|
|
if (!buckets.has(key)) buckets.set(key, []);
|
|
buckets.get(key).push(row);
|
|
}
|
|
|
|
const known = order.filter((k) => buckets.get(k).length > 0);
|
|
const unknown = [...buckets.keys()].filter((k) => !order.includes(k) && buckets.get(k).length > 0);
|
|
return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) }));
|
|
}
|
|
|
|
function reconcileConcepts(rows, conceptNames) {
|
|
const named = new Set(conceptNames);
|
|
const rowed = new Set(rows.map((r) => r.concept));
|
|
return {
|
|
rows,
|
|
unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(),
|
|
stale: rows.filter((r) => !named.has(r.concept)),
|
|
};
|
|
}
|
|
|
|
// Defence in depth. The spawn path no longer uses a shell, so nothing here is
|
|
// load-bearing against injection today — but the guard costs nothing and would
|
|
// still hold if the single-string `shell: true` form is ever needed for a .cmd
|
|
// shim. `^` is cmd.exe's escape character; control characters are rejected
|
|
// because a NUL byte makes spawn() throw synchronously.
|
|
//
|
|
// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they appear in
|
|
// real filenames in this vault.
|
|
const UNSAFE_CHARS = /["`$&|;<>%^\u0000-\u001f\u007f]/;
|
|
|
|
function isSafeFilename(name) {
|
|
if (typeof name !== "string" || name.length === 0) return false;
|
|
if (UNSAFE_CHARS.test(name)) return false;
|
|
if (name.includes("..")) return false;
|
|
return true;
|
|
}
|
|
|
|
function parseConfig(source) {
|
|
const cfg = Object.assign({}, DEFAULTS);
|
|
for (const line of String(source).split(/\r?\n/)) {
|
|
const m = line.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*$/);
|
|
if (m && Object.prototype.hasOwnProperty.call(DEFAULTS, m[1])) {
|
|
cfg[m[1]] = m[2];
|
|
}
|
|
}
|
|
return cfg;
|
|
}
|
|
|
|
// Source types CLAUDE.md documents for raw/sources. Anything else in that
|
|
// folder is not a source and stays out of the queue.
|
|
const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]);
|
|
|
|
const ICONS = {
|
|
terminal: "M4 17l6-6-6-6M12 19h8",
|
|
check: "M20 6 9 17l-5-5",
|
|
minus: "M5 12h14",
|
|
x: "M18 6 6 18M6 6l12 12",
|
|
refresh: "M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3L21 8M21 3v5h-5 M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3L3 16M3 21v-5h5",
|
|
};
|
|
|
|
// Built with createElementNS rather than Obsidian's createSvg helper, whose
|
|
// availability varies by version. This works on any Obsidian build.
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
function addIcon(parent, name) {
|
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("viewBox", "0 0 24 24");
|
|
svg.setAttribute("fill", "none");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
const path = document.createElementNS(SVG_NS, "path");
|
|
path.setAttribute("d", ICONS[name]);
|
|
path.setAttribute("stroke", "currentColor");
|
|
path.setAttribute("stroke-width", "1.75");
|
|
path.setAttribute("stroke-linecap", "round");
|
|
path.setAttribute("stroke-linejoin", "round");
|
|
svg.appendChild(path);
|
|
parent.appendChild(svg);
|
|
return svg;
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
return `${n.toLocaleString("en-US")} B`;
|
|
}
|
|
|
|
function renderLeftPane(container, pipeline, onIngest) {
|
|
const pane = container.createDiv({ cls: "wd-pane" });
|
|
|
|
const queue = pane.createDiv({ cls: "wd-block" });
|
|
queue.createDiv({
|
|
cls: "wd-eyebrow",
|
|
text: `Queue — ${pipeline.unprocessed.length} unprocessed`,
|
|
});
|
|
if (pipeline.unprocessed.length === 0) {
|
|
queue.createDiv({ cls: "wd-note", text: "Every raw source has a summary page." });
|
|
}
|
|
for (const file of pipeline.unprocessed) {
|
|
const row = queue.createDiv({ cls: "wd-row" });
|
|
const main = row.createDiv({ cls: "wd-row-main" });
|
|
main.createDiv({ cls: "wd-row-name", text: file.name });
|
|
main.createDiv({ cls: "wd-mono", text: formatBytes(file.size) });
|
|
const btn = row.createEl("button", { cls: "wd-btn" });
|
|
addIcon(btn, "terminal");
|
|
btn.createSpan({ text: "Ingest" });
|
|
btn.addEventListener("click", () => onIngest(file, row));
|
|
}
|
|
|
|
const done = pane.createDiv({ cls: "wd-block" });
|
|
done.createDiv({ cls: "wd-eyebrow", text: `Ingested — ${pipeline.processed.length}` });
|
|
const list = done.createDiv({ cls: "wd-done" });
|
|
for (const file of pipeline.processed) {
|
|
const row = list.createDiv({ cls: "wd-done-row" });
|
|
const date = file.page.name.slice(0, 10);
|
|
row.createSpan({ cls: "wd-mono", text: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "—" });
|
|
row.createSpan({ cls: "wd-done-name", text: file.name.replace(/\.md$/, "") });
|
|
}
|
|
|
|
if (pipeline.orphaned.length > 0) {
|
|
const orphan = pane.createDiv({ cls: "wd-block" });
|
|
orphan.createDiv({
|
|
cls: "wd-eyebrow",
|
|
text: `Orphaned — ${pipeline.orphaned.length}`,
|
|
});
|
|
for (const page of pipeline.orphaned) {
|
|
const row = orphan.createDiv({ cls: "wd-done-row" });
|
|
row.createSpan({ cls: "wd-done-name", text: page.name });
|
|
row.createSpan({ cls: "wd-mono", text: page.rawPath || "no raw path" });
|
|
}
|
|
}
|
|
|
|
return pane;
|
|
}
|
|
|
|
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
|
|
|
|
function renderRightPane(container, parsed, reconciled) {
|
|
const pane = container.createDiv({ cls: "wd-pane" });
|
|
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
|
|
|
|
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(
|
|
"aria-label",
|
|
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
|
|
);
|
|
for (const key of ["covered", "partial", "absent"]) {
|
|
if (counts[key] === 0) continue;
|
|
const seg = meter.createEl("i", { cls: `wd-seg-${key}` });
|
|
seg.style.flex = String(counts[key]);
|
|
}
|
|
const key = pane.createDiv({ cls: "wd-key" });
|
|
for (const name of ["covered", "partial", "absent"]) {
|
|
const span = key.createSpan();
|
|
span.createEl("i", { cls: `wd-seg-${name}` });
|
|
span.createSpan({ text: `${counts[name]} ${name}` });
|
|
}
|
|
}
|
|
|
|
if (parsed.errors.length > 0) {
|
|
const box = pane.createDiv({ cls: "wd-error" });
|
|
box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` });
|
|
for (const e of parsed.errors) {
|
|
box.createDiv({ cls: "wd-mono", text: `line ${e.line} — ${e.reason}` });
|
|
}
|
|
}
|
|
|
|
const table = pane.createEl("table", { cls: "wd-tbl" });
|
|
const head = table.createEl("thead").createEl("tr");
|
|
for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h });
|
|
const body = table.createEl("tbody");
|
|
|
|
for (const group of groupByStation(parsed.rows)) {
|
|
const gr = body.createEl("tr", { cls: "wd-grp" });
|
|
gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station} — ${group.rows.length}` });
|
|
for (const row of group.rows) {
|
|
const tr = body.createEl("tr");
|
|
// Obsidian's click handler resolves internal links via data-href, so both
|
|
// attributes are required for the link to open the concept page.
|
|
tr.createEl("td").createEl("a", {
|
|
cls: "internal-link",
|
|
text: row.concept,
|
|
attr: { href: row.concept, "data-href": row.concept },
|
|
});
|
|
const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` });
|
|
addIcon(stat, STATUS_ICON[row.status]);
|
|
stat.createSpan({ text: row.status });
|
|
tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" });
|
|
tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" });
|
|
}
|
|
}
|
|
|
|
if (reconciled.unsynced.length > 0) {
|
|
const box = pane.createDiv({ cls: "wd-block" });
|
|
box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` });
|
|
box.createDiv({
|
|
cls: "wd-note",
|
|
text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`,
|
|
});
|
|
}
|
|
|
|
if (reconciled.stale.length > 0) {
|
|
const box = pane.createDiv({ cls: "wd-block" });
|
|
box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` });
|
|
box.createDiv({
|
|
cls: "wd-note",
|
|
text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`,
|
|
});
|
|
}
|
|
|
|
return pane;
|
|
}
|
|
|
|
class WebinarDashPlugin extends PluginBase {
|
|
async onload() {
|
|
this.running = new Set();
|
|
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}` });
|
|
}
|
|
});
|
|
}
|
|
|
|
async readCoverage(cfg) {
|
|
const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
|
|
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
|
|
const parsed = parseCoverageTable(await this.app.vault.cachedRead(file));
|
|
|
|
const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/";
|
|
const conceptNames = this.app.vault
|
|
.getFiles()
|
|
.filter((f) => f.path.startsWith(conceptDir) && f.extension === "md")
|
|
.map((f) => f.basename);
|
|
|
|
return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) };
|
|
}
|
|
|
|
async readPipeline(cfg) {
|
|
const all = this.app.vault.getFiles();
|
|
const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/";
|
|
const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/";
|
|
|
|
// CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports".
|
|
// Allow-listing only "md" would hide the others from the queue with no
|
|
// warning — the same silent-invisibility failure this dashboard exists to
|
|
// remove. Wiki source pages below stay markdown-only; those really are .md.
|
|
const rawFiles = all
|
|
.filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension))
|
|
.map((f) => ({ path: f.path, name: f.name, size: f.stat.size }));
|
|
|
|
const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md");
|
|
const sourcePages = [];
|
|
for (const f of pageFiles) {
|
|
const text = await this.app.vault.cachedRead(f);
|
|
sourcePages.push({ path: f.path, name: f.name, rawPath: extractRawPath(text) });
|
|
}
|
|
|
|
return derivePipeline({ rawFiles, sourcePages });
|
|
}
|
|
|
|
vaultPath() {
|
|
const adapter = this.app.vault.adapter;
|
|
if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
|
|
return null;
|
|
}
|
|
|
|
runIngest(file, rowEl) {
|
|
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}.`);
|
|
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.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
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 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");
|
|
if (button) button.disabled = true;
|
|
this.running.add(file.path);
|
|
|
|
const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
|
|
const output = rowEl.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.
|
|
let buffered = "";
|
|
const append = (text) => {
|
|
buffered = (buffered + text).slice(-8000);
|
|
output.setText(buffered);
|
|
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) => {
|
|
window.clearInterval(timer);
|
|
this.running.delete(file.path);
|
|
status.className = `wd-status ${cls}`;
|
|
status.setText(label);
|
|
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 });
|
|
} 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);
|
|
child.stderr.on("data", append);
|
|
|
|
child.on("error", (err) => {
|
|
append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
|
|
finish("wd-status-failed", "failed");
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
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.`);
|
|
} else {
|
|
finish("wd-status-failed", `failed - exit ${code}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = WebinarDashPlugin;
|
|
module.exports.default = WebinarDashPlugin;
|
|
module.exports.__test__ = {
|
|
parseConfig, extractRawPath, derivePipeline,
|
|
parseCoverageTable, groupByStation, reconcileConcepts,
|
|
STATIONS, DEFAULTS, isSafeFilename,
|
|
};
|