Measured chain from live preview showed the cap on .cm-content at 700px, while .cm-sizer - the element closest() matched first - is already uncapped at 1680px. The class-name shortcut therefore locked onto the wrong element and returned before the fallback walk could find the real one, so the cap survived and the container query correctly collapsed the grid to one column. widenHost now always measures computed max-width outward from the dashboard's parent. The decision is extracted as firstCappedIndex and tested against the real measured chains for both live preview and reading view, so a class-name assumption cannot silently break it again. Adds .cm-content to the CSS fallback.
658 lines
24 KiB
JavaScript
658 lines
24 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);
|
|
// 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());
|
|
|
|
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"]);
|
|
|
|
// How far up from the dashboard to look for the element carrying Obsidian's
|
|
// readable-line-width cap. Measured chains reach it in 3 hops; 8 is slack.
|
|
const MAX_WIDEN_HOPS = 8;
|
|
|
|
// Given each ancestor's computed max-width, walking outward from the
|
|
// dashboard's parent, return the index of the first that actually carries a
|
|
// cap — or -1 if none does before the workspace chrome begins.
|
|
//
|
|
// Split out from the DOM walk so the decision can be tested against real
|
|
// measured chains. Class-name matching got this wrong twice: in live preview
|
|
// the cap sits on `.cm-content`, while `.cm-sizer` is already uncapped.
|
|
function firstCappedIndex(ancestors) {
|
|
for (let i = 0; i < ancestors.length && i < MAX_WIDEN_HOPS; i += 1) {
|
|
if (ancestors[i].isWorkspaceLeaf) return -1;
|
|
const max = ancestors[i].maxWidth;
|
|
if (max && max !== "none") return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
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, gate) {
|
|
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" });
|
|
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" });
|
|
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, 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}`
|
|
);
|
|
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" });
|
|
await this.renderAll(root, cfg);
|
|
});
|
|
}
|
|
|
|
// Obsidian caps note content at --file-line-width when readable line length
|
|
// is on, which squeezes a two-pane dashboard into a column. Lift the cap on
|
|
// whichever ancestor carries it.
|
|
//
|
|
// Done here rather than in CSS on purpose: an inline style beats the app
|
|
// stylesheet without an !important arms race, and the computed-style walk
|
|
// means this keeps working even if Obsidian renames the sizer classes. The
|
|
// walk stops at the first capped ancestor and is bounded, so it cannot climb
|
|
// out into the workspace chrome and widen something it shouldn't.
|
|
widenHost(root) {
|
|
// Deliberately measured, not matched by class name. In live preview the cap
|
|
// sits on `.cm-content` (700px) while `.cm-sizer` — the obvious candidate,
|
|
// and the one a class-based lookup finds first — is already uncapped at full
|
|
// width. Shortcutting via closest() therefore locks onto the wrong element
|
|
// and stops before reaching the real one. Reading computed max-width finds
|
|
// whichever element actually carries the cap, in either view.
|
|
//
|
|
// Starts at root.parentElement so the dashboard's own 1600px cap survives,
|
|
// stops at the first capped ancestor so nothing above the note widens, and
|
|
// is bounded so it cannot climb into the workspace chrome.
|
|
const chain = [];
|
|
let el = root.parentElement;
|
|
for (let hops = 0; el && hops < MAX_WIDEN_HOPS; hops += 1, el = el.parentElement) {
|
|
chain.push({
|
|
el,
|
|
maxWidth: window.getComputedStyle(el).maxWidth,
|
|
isWorkspaceLeaf: el.classList.contains("workspace-leaf"),
|
|
});
|
|
}
|
|
const idx = firstCappedIndex(chain);
|
|
if (idx < 0) return null;
|
|
chain[idx].el.style.maxWidth = "none";
|
|
return chain[idx].el;
|
|
}
|
|
|
|
async renderAll(root, cfg) {
|
|
root.empty();
|
|
this.widenHost(root);
|
|
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}`);
|
|
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;
|
|
}
|
|
|
|
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); };
|
|
|
|
if (this.running.has(key)) {
|
|
notify(`Already running: ${label}.`);
|
|
return;
|
|
}
|
|
|
|
const gate = this.spawnGate();
|
|
if (!gate.ok) {
|
|
notify(gate.reason);
|
|
return;
|
|
}
|
|
const { spawn } = require("child_process");
|
|
const base = this.vaultPath();
|
|
|
|
// 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());
|
|
|
|
const button = hostEl.querySelector("button");
|
|
if (button) button.disabled = true;
|
|
this.running.add(key);
|
|
|
|
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.
|
|
let buffered = "";
|
|
const append = (text) => {
|
|
buffered = (buffered + text).slice(-8000);
|
|
output.setText(buffered);
|
|
output.scrollTop = output.scrollHeight;
|
|
};
|
|
|
|
// 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(key);
|
|
status.className = `wd-status ${cls}`;
|
|
status.setText(text);
|
|
if (button) button.disabled = false;
|
|
};
|
|
|
|
let child;
|
|
try {
|
|
// See the C1 comment in runIngest: no shell, deliberately.
|
|
child = spawn("claude", ["-p", prompt], { cwd: base });
|
|
} catch (err) {
|
|
append(`\nCould not start claude: ${err.message}`);
|
|
finish("wd-status-failed", "failed");
|
|
return;
|
|
}
|
|
|
|
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`);
|
|
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;
|
|
module.exports.default = WebinarDashPlugin;
|
|
module.exports.__test__ = {
|
|
parseConfig, extractRawPath, derivePipeline,
|
|
parseCoverageTable, groupByStation, reconcileConcepts,
|
|
STATIONS, DEFAULTS, firstCappedIndex, isSafeFilename,
|
|
};
|