diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index acf5d03..9d0aa1f 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -65,6 +65,87 @@ function derivePipeline({ rawFiles, sourcePages }) { 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 parseConfig(source) { const cfg = Object.assign({}, DEFAULTS); for (const line of String(source).split(/\r?\n/)) { @@ -200,4 +281,8 @@ class WebinarDashPlugin extends PluginBase { module.exports = WebinarDashPlugin; module.exports.default = WebinarDashPlugin; -module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATIONS, DEFAULTS }; +module.exports.__test__ = { + parseConfig, extractRawPath, derivePipeline, + parseCoverageTable, groupByStation, + STATIONS, DEFAULTS, +}; diff --git a/.obsidian/plugins/webinar-dash/test/coverage.test.js b/.obsidian/plugins/webinar-dash/test/coverage.test.js new file mode 100644 index 0000000..c326d4c --- /dev/null +++ b/.obsidian/plugins/webinar-dash/test/coverage.test.js @@ -0,0 +1,103 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { parseCoverageTable, groupByStation } = require("../main.js").__test__; + +const DOC = [ + "# Script coverage", + "", + "#coverage", + "", + "## Metadata", + "", + "- **Script:** `raw/sources/Webinar script.md`", + "- **Last synced:** 2026-07-28", + "", + "## Coverage", + "", + "| Concept | Status | Station | Pinned |", + "|---|---|---|---|", + "| [[harness]] | covered | Tools | |", + "| [[agentic-loops]] | partial | Process | |", + "| [[levels-of-ai-usage]] | partial | all | |", + "| [[connections-as-moat]] | absent | — | yes |", +].join("\n"); + +test("parseCoverageTable reads metadata", () => { + const { meta } = parseCoverageTable(DOC); + assert.equal(meta.script, "raw/sources/Webinar script.md"); + assert.equal(meta.lastSynced, "2026-07-28"); +}); + +test("parseCoverageTable reads every data row and skips the header", () => { + const { rows, errors } = parseCoverageTable(DOC); + assert.equal(errors.length, 0); + assert.equal(rows.length, 4); + assert.deepEqual(rows.map((r) => r.concept), [ + "harness", "agentic-loops", "levels-of-ai-usage", "connections-as-moat", + ]); +}); + +test("parseCoverageTable normalises stations", () => { + const { rows } = parseCoverageTable(DOC); + assert.deepEqual(rows[0].stations, ["Tools"]); + assert.deepEqual(rows[2].stations, ["all"]); + assert.deepEqual(rows[3].stations, []); +}); + +test("parseCoverageTable reads the pinned flag", () => { + const { rows } = parseCoverageTable(DOC); + assert.equal(rows[0].pinned, false); + assert.equal(rows[3].pinned, true); +}); + +test("parseCoverageTable strips a wikilink alias", () => { + const doc = "| Concept | Status | Station |\n|---|---|---|\n| [[harness\\|The harness]] | covered | Tools |"; + const { rows } = parseCoverageTable(doc); + assert.equal(rows[0].concept, "harness"); +}); + +test("parseCoverageTable splits a multi-station cell", () => { + const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered | Tools, Memory |"; + const { rows } = parseCoverageTable(doc); + assert.deepEqual(rows[0].stations, ["Tools", "Memory"]); +}); + +test("parseCoverageTable rejects an invalid status instead of coercing it", () => { + const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | maybe | Tools |"; + const { rows, errors } = parseCoverageTable(doc); + assert.equal(rows.length, 0); + assert.equal(errors.length, 1); + assert.match(errors[0].reason, /invalid status/); + assert.equal(errors[0].line, 3); +}); + +test("parseCoverageTable reports a row with too few columns", () => { + const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered |"; + const { rows, errors } = parseCoverageTable(doc); + assert.equal(rows.length, 0); + assert.equal(errors.length, 1); + assert.match(errors[0].reason, /at least 3 columns/); +}); + +test("parseCoverageTable returns empty results for a document with no table", () => { + const { rows, errors } = parseCoverageTable("# Nothing\n\nJust prose."); + assert.equal(rows.length, 0); + assert.equal(errors.length, 0); +}); + +test("groupByStation orders all-stations first and no-station last", () => { + const { rows } = parseCoverageTable(DOC); + const groups = groupByStation(rows); + assert.deepEqual(groups.map((g) => g.station), [ + "All stations", "Tools", "Process", "No station", + ]); + assert.equal(groups[1].rows[0].concept, "harness"); +}); + +test("groupByStation places a multi-station row under its first station only", () => { + const rows = [{ concept: "x", status: "covered", stations: ["Memory", "Skills"], pinned: false, line: 1 }]; + const groups = groupByStation(rows); + assert.equal(groups.length, 1); + assert.equal(groups[0].station, "Memory"); +});