feat: parse the script coverage table

This commit is contained in:
meels
2026-07-28 13:45:24 +02:00
parent 3e28180f8b
commit 0304757982
2 changed files with 189 additions and 1 deletions

View File

@@ -65,6 +65,87 @@ function derivePipeline({ rawFiles, sourcePages }) {
return { processed, unprocessed, orphaned }; 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 parseConfig(source) { function parseConfig(source) {
const cfg = Object.assign({}, DEFAULTS); const cfg = Object.assign({}, DEFAULTS);
for (const line of String(source).split(/\r?\n/)) { for (const line of String(source).split(/\r?\n/)) {
@@ -200,4 +281,8 @@ class WebinarDashPlugin extends PluginBase {
module.exports = WebinarDashPlugin; module.exports = WebinarDashPlugin;
module.exports.default = WebinarDashPlugin; module.exports.default = WebinarDashPlugin;
module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATIONS, DEFAULTS }; module.exports.__test__ = {
parseConfig, extractRawPath, derivePipeline,
parseCoverageTable, groupByStation,
STATIONS, DEFAULTS,
};

View File

@@ -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");
});