feat: add CSV support for journal stat blocks

This commit is contained in:
hypercross 2026-07-09 09:47:17 +08:00
parent 138c089514
commit 45cd9a01ac
2 changed files with 138 additions and 2 deletions

View File

@ -99,6 +99,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
const stats: StatDef[] = []; const stats: StatDef[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/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) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
@ -164,7 +165,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
i = j - 1; i = j - 1;
} }
// Stat block scan // Stat block scan (YAML)
statBlockRegex.lastIndex = 0; statBlockRegex.lastIndex = 0;
let statMatch: RegExpExecArray | null; let statMatch: RegExpExecArray | null;
while ((statMatch = statBlockRegex.exec(content)) !== null) { while ((statMatch = statBlockRegex.exec(content)) !== null) {
@ -172,6 +173,14 @@ async function scanClientSide(): Promise<JournalCompletions> {
const parsed = parseStatYaml(yaml, filePath); const parsed = parseStatYaml(yaml, filePath);
stats.push(...parsed); 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 }; return { dice, links, sparkTables, stats };
@ -284,6 +293,95 @@ function parseStatYaml(yaml: string, source: string): StatDef[] {
return defs; 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) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately // Using a top-level IIFE so the promise starts immediately

View File

@ -40,7 +40,11 @@ props:
## 属性定义语法 ## 属性定义语法
在任意 `.md` 文档中插入 ` ```yaml role=stat ` 代码块: 支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。
### YAML 格式
`.md` 文档中插入 ` ```yaml role=stat ` 代码块:
```yaml role=stat ```yaml role=stat
- key: strength - 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`),不用加玩家前缀 - **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀