feat: parse the script coverage table
This commit is contained in:
87
.obsidian/plugins/webinar-dash/main.js
vendored
87
.obsidian/plugins/webinar-dash/main.js
vendored
@@ -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(/(?<!\\)\|/).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) {
|
||||
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,
|
||||
};
|
||||
|
||||
103
.obsidian/plugins/webinar-dash/test/coverage.test.js
vendored
Normal file
103
.obsidian/plugins/webinar-dash/test/coverage.test.js
vendored
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user