diff --git a/src/cli/completions/directive-scanner.ts b/src/cli/completions/directive-scanner.ts index 49284a0..36841d2 100644 --- a/src/cli/completions/directive-scanner.ts +++ b/src/cli/completions/directive-scanner.ts @@ -135,6 +135,7 @@ export function inspectSparkTableCsv(csv: string): string[] | null { export function buildSparkTableCompletion( csv: string, filePath: string, + csvPath: string, slugger: Slugger, ): SparkTableCompletion | null { const dataHeaders = inspectSparkTableCsv(csv); @@ -156,6 +157,7 @@ export function buildSparkTableCompletion( notation: headers[0], slug: combinedSlug, filePath: basePath, + csvPath, headers: dataHeaders, }; } @@ -246,7 +248,7 @@ export function scanDirectives( newIndexEntries[resolvedPath] = csv; // Collect spark table completion - const st = buildSparkTableCompletion(csv, filePath, slugger); + const st = buildSparkTableCompletion(csv, filePath, resolvedPath, slugger); if (st) { sparkTables.push(st); } @@ -288,7 +290,7 @@ export function scanDirectives( let csv = index[csvPath] ?? newIndexEntries[csvPath]; if (!csv) continue; - const st = buildSparkTableCompletion(csv, filePath, slugger); + const st = buildSparkTableCompletion(csv, filePath, csvPath, slugger); if (!st) continue; // Check if data-spark is already set in extra attrs diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index 6ced9da..15d273f 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -46,8 +46,10 @@ export interface SparkTableCompletion { notation: string; /** Concatenated slug of data column headers */ slug: string; - /** File path of the containing .md file */ + /** File path of the containing .md file (without extension) */ filePath: string; + /** Resolved path to the .csv file backing this spark table */ + csvPath: string; /** Data column headers for display */ headers: string[]; } diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 516f49f..9eb99de 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -127,8 +127,8 @@ export const JournalInput: Component = () => { try { const key = (parsed.payload as { key: string }).key; const match = comp.data.sparkTables.find((s) => s.slug === key); - const filePath = match?.filePath ?? ""; - const p = await resolveSparkPayload({ key, filePath }); + const csvPath = match?.csvPath ?? ""; + const p = await resolveSparkPayload({ key, csvPath }); const result = sendMessage("spark", p); const r = unwrap(result); finish(r.ok, r.err); diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 021a07d..c8417e1 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -29,8 +29,6 @@ import { } from "../../cli/completions/block-scanner"; import { scanDirectives, - buildSparkTableCompletion, - inspectSparkTableCsv, } from "../../cli/completions/directive-scanner"; export type { StatDef, StatTemplate, StatSheet }; @@ -54,6 +52,7 @@ export interface SparkTableCompletion { notation: string; slug: string; filePath: string; + csvPath: string; headers: string[]; } diff --git a/src/components/journal/types/spark.tsx b/src/components/journal/types/spark.tsx index ced0121..80c4d81 100644 --- a/src/components/journal/types/spark.tsx +++ b/src/components/journal/types/spark.tsx @@ -17,9 +17,8 @@ import { For } from "solid-js"; import { registerMessageType } from "../registry"; import { rollFormula } from "../../md-commander/hooks"; import { - findSparkTableByCombinedSlug, + parseSparkTableCsv, rollSparkTable, - scanSparkTables, } from "../../utils/spark-table"; import { getIndexedData } from "../../../data-loader/file-index"; @@ -70,33 +69,23 @@ export type SparkPayload = z.infer; * Resolve a spark table roll. * * `key` is the combined slug (pageName-columnSlug). - * `filePath` is the .md file path (without .md extension) from completions. + * `csvPath` is the resolved .csv file path from completions. */ export async function resolveSparkPayload(raw: { key: string; - filePath: string; + csvPath: string; }): Promise { - const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`; - const pageName = - raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() || - raw.filePath; - - let content: string; + let csv: string; try { - content = await getIndexedData(mdPath); + csv = await getIndexedData(raw.csvPath); } catch { - throw new Error(`Failed to load file: "${mdPath}"`); + throw new Error(`Failed to load CSV: "${raw.csvPath}"`); } - const meta = findSparkTableByCombinedSlug(content, raw.key, pageName); + const meta = parseSparkTableCsv(csv); if (!meta) { - const available = scanSparkTables(content); - const names = - available.length > 0 - ? available.map((t) => `${pageName}-${t.slug}`).join(", ") - : "(none)"; throw new Error( - `Spark table "${raw.key}" not found in "${mdPath}". Available: ${names}`, + `Spark table "${raw.key}" not found in CSV "${raw.csvPath}"`, ); } diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts index 7b4fadd..77c1e40 100644 --- a/src/components/utils/spark-table.ts +++ b/src/components/utils/spark-table.ts @@ -8,6 +8,7 @@ */ import Slugger from "github-slugger"; +import { parseCSVString } from "../utils/csv-loader"; import { rollFormula } from "../md-commander/hooks"; // --------------------------------------------------------------------------- @@ -41,8 +42,8 @@ export interface SparkTableMeta { slug: string; /** Data column headers (excluding dice column) */ dataHeaders: string[]; - /** Full list of rows */ - rows: string[][]; + /** Full list of rows as objects keyed by header */ + rows: Record[]; } // --------------------------------------------------------------------------- @@ -141,7 +142,7 @@ export function findSparkTable( notation: table.headers[0], slug, dataHeaders: table.headers.slice(1), - rows: table.rows, + rows: rowsToObjects(table), }; } } @@ -169,13 +170,50 @@ export function findSparkTableByCombinedSlug( notation: table.headers[0], slug: colSlug, dataHeaders: table.headers.slice(1), - rows: table.rows, + rows: rowsToObjects(table), }; } } return null; } +/** Convert a MarkdownTable's string[][] rows to Record[] */ +function rowsToObjects(table: MarkdownTable): Record[] { + return table.rows.map((row) => { + const obj: Record = {}; + for (let i = 0; i < table.headers.length; i++) { + obj[table.headers[i]] = row[i] ?? ""; + } + return obj; + }); +} + +/** + * Parse a CSV string into a SparkTableMeta. + * The CSV must have a dice formula as its first column header. + * Returns null if the CSV is not a valid spark table. + */ +export function parseSparkTableCsv(csv: string): SparkTableMeta | null { + const parsed = parseCSVString(csv); + const headers = Object.keys(parsed[0] ?? {}); + + if (headers.length < 2) return null; + if (!DICE_HEADER_RE.test(headers[0])) return null; + + const slugger = new Slugger(); + const dataHeaders = headers.slice(1); + const slug = dataHeaders + .map((h) => slugger.slug(h.toLowerCase())) + .join("-"); + + return { + notation: headers[0], + slug, + dataHeaders, + rows: parsed as Record[], + }; +} + /** Scan all spark tables in a markdown file and return their metadata */ export function scanSparkTables( markdown: string, @@ -237,8 +275,9 @@ export function rollSparkTable(meta: SparkTableMeta): SparkTableResult { const slugger = new Slugger(); const columns: SparkTableColumn[] = []; - for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) { - const header = meta.dataHeaders[colIdx]; + const diceHeader = Object.keys(meta.rows[0] ?? {})[0] ?? ""; + + for (const header of meta.dataHeaders) { const slug = slugger.slug(header.toLowerCase()); const roll = rollFormula(meta.notation); @@ -247,9 +286,9 @@ export function rollSparkTable(meta: SparkTableMeta): SparkTableResult { // Find the row matching the rolled value let value = `(no row for ${rolledValue})`; for (const row of meta.rows) { - const diceCell = row[0] ?? ""; + const diceCell = row[diceHeader] ?? ""; if (matchesCell(diceCell, rolledValue)) { - value = row[colIdx + 1] ?? ""; + value = row[header] ?? ""; break; } }