"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(/(? 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)), }; } // `shell: true` is required on Windows to resolve `claude.cmd`, which puts the // filename into a shell string. Reject anything cmd.exe or a POSIX shell would // interpret. Apostrophes and cyrillic are safe inside double quotes and are // present in real filenames, so they stay allowed. const UNSAFE_CHARS = /["`$&|;<>%\r\n]/; 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.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) { if (rowEl.dataset.wdRunning === "1") return; const Notice = OB ? OB.Notice : null; const notify = (msg) => { if (Notice) new Notice(msg); }; 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; } rowEl.dataset.wdRunning = "1"; const button = rowEl.querySelector("button"); if (button) button.disabled = true; const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); const started = Date.now(); const timer = window.setInterval(() => { status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); }, 1000); const output = rowEl.createDiv({ cls: "wd-output", text: "" }); let buffered = ""; const append = (chunk) => { buffered += chunk.toString(); output.setText(buffered.slice(-4000)); output.scrollTop = output.scrollHeight; }; const child = spawn("claude", ["-p", `ingest "${file.name}"`], { cwd: base, shell: true, }); child.stdout.on("data", append); child.stderr.on("data", append); child.on("error", (err) => { window.clearInterval(timer); status.className = "wd-status wd-status-failed"; status.setText("failed"); append(`\nCould not start claude: ${err.message}\nIs it on PATH?`); rowEl.dataset.wdRunning = "0"; if (button) button.disabled = false; }); child.on("close", (code) => { window.clearInterval(timer); const secs = Math.round((Date.now() - started) / 1000); if (code === 0) { status.className = "wd-status wd-status-done"; status.setText(`done in ${secs}s`); notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); } else { status.className = "wd-status wd-status-failed"; status.setText(`failed - exit ${code}`); } rowEl.dataset.wdRunning = "0"; if (button) button.disabled = false; }); } } module.exports = WebinarDashPlugin; module.exports.default = WebinarDashPlugin; module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, parseCoverageTable, groupByStation, reconcileConcepts, STATIONS, DEFAULTS, isSafeFilename, };