500 lines
17 KiB
JavaScript
500 lines
17 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)),
|
|
};
|
|
}
|
|
|
|
// `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,
|
|
};
|