From 45cd9a01ac3688c79dab4bb48ade0e6ed445eb87 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 9 Jul 2026 09:47:17 +0800 Subject: [PATCH] feat: add CSV support for journal stat blocks --- src/components/journal/completions.ts | 100 +++++++++++++++++++++++++- src/doc-entries/journal-stat.md | 40 ++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 70d65ed..82227f2 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -99,6 +99,7 @@ async function scanClientSide(): Promise { const stats: StatDef[] = []; const tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; + const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; for (const filePath of paths) { const content = await getIndexedData(filePath); @@ -164,7 +165,7 @@ async function scanClientSide(): Promise { i = j - 1; } - // Stat block scan + // Stat block scan (YAML) statBlockRegex.lastIndex = 0; let statMatch: RegExpExecArray | null; while ((statMatch = statBlockRegex.exec(content)) !== null) { @@ -172,6 +173,14 @@ async function scanClientSide(): Promise { const parsed = parseStatYaml(yaml, filePath); stats.push(...parsed); } + + // Stat block scan (CSV) + statCsvRegex.lastIndex = 0; + while ((statMatch = statCsvRegex.exec(content)) !== null) { + const csv = statMatch[1]; + const parsed = parseStatCsv(csv, filePath); + stats.push(...parsed); + } } return { dice, links, sparkTables, stats }; @@ -284,6 +293,95 @@ function parseStatYaml(yaml: string, source: string): StatDef[] { return defs; } +// --------------------------------------------------------------------------- +// Stat CSV parser +// --------------------------------------------------------------------------- + +/** + * Parse a ```csv role=stat block into StatDef entries. + * + * Columns: key, label, type, scope, default, roll, target, formula, options + * Only `key` is required. `type` defaults to "number", `scope` to "player". + * `options` column uses pipe-separated values: "a|b|c". + */ +function parseStatCsv(csv: string, source: string): StatDef[] { + const lines = csv.trim().split(/\r?\n/); + if (lines.length === 0) return []; + + // Parse header + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const idx = (name: string) => { + const i = headers.indexOf(name); + return i === -1 ? null : i; + }; + + const defs: StatDef[] = []; + + for (let i = 1; i < lines.length; i++) { + const row = lines[i].trim(); + if (!row || row.startsWith("#")) continue; + const cols = splitCsvRow(row); + if (cols.length === 0) continue; + + const get = (name: string) => { + const colIdx = idx(name); + if (colIdx === null || colIdx >= cols.length) return undefined; + return cols[colIdx].trim() || undefined; + }; + + const key = get("key"); + if (!key) continue; + + const type = (get("type") || "number") as StatDef["type"]; + const scope = (get("scope") || "player") as StatDef["scope"]; + + let options: string[] | undefined; + const optRaw = get("options"); + if (optRaw) { + options = optRaw + .split("|") + .map((s) => s.trim()) + .filter(Boolean); + } + + defs.push({ + key, + label: get("label") || key, + type, + scope, + default: get("default"), + roll: get("roll"), + target: get("target"), + formula: get("formula"), + options, + source, + }); + } + + return defs; +} + +/** Split a CSV row respecting quoted fields. */ +function splitCsvRow(row: string): string[] { + const cols: string[] = []; + let current = ""; + let inQuote = false; + + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === "," && !inQuote) { + cols.push(current); + current = ""; + } else { + current += ch; + } + } + cols.push(current); + return cols; +} + // ------------------- Init (runs eagerly at import time) ------------------- // Using a top-level IIFE so the promise starts immediately diff --git a/src/doc-entries/journal-stat.md b/src/doc-entries/journal-stat.md index 177be31..cb8d794 100644 --- a/src/doc-entries/journal-stat.md +++ b/src/doc-entries/journal-stat.md @@ -40,7 +40,11 @@ props: ## 属性定义语法 -在任意 `.md` 文档中插入 ` ```yaml role=stat ` 代码块: +支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。 + +### YAML 格式 + +在 `.md` 文档中插入 ` ```yaml role=stat ` 代码块: ```yaml role=stat - key: strength @@ -93,6 +97,40 @@ props: - 暴风雨 ``` +### CSV 格式 + +对于同质属性列表(如多个 `number` 类型的属性),CSV 更紧凑。 +插入 ` ```csv role=stat ` 代码块: + +```csv role=stat +key,label,type,roll +mind,心智,number,2d10+20 +heart,心灵,number,2d10+20 +strength,力量,number,2d10+20 +speed,速度,number,2d10+20 +``` + +CSV 列说明: + +| 列 | 必填 | 默认值 | 说明 | +|---|---|---|---| +| `key` | ✅ | — | 属性标识符 | +| `label` | — | key 的值 | 显示名称 | +| `type` | — | `number` | 属性类型 | +| `scope` | — | `player` | `player` 或 `global` | +| `default` | — | — | 默认值 | +| `roll` | — | — | 掷骰公式 | +| `target` | — | — | modifier 的目标 key | +| `formula` | — | — | derived 的计算公式 | +| `options` | — | — | enum 选项,用 `\|` 分隔 | + +示例 — enum 属性: + +```csv role=stat +key,label,type,options +weather,天气,enum,晴天\|阴天\|雨天\|暴风雨 +``` + ### 关键语法说明 - **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀