diff --git a/docs/mcp.md b/docs/mcp.md index 8666aef..795f9be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # MCP 服务器使用说明 -TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成卡牌内容。 +TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成和管理卡牌内容。 ## 命令结构 @@ -79,11 +79,15 @@ ttrpg mcp generate-card-deck \ --grid "3x4" ``` -## MCP 工具:generate_card_deck +## MCP 工具 -通过 MCP 协议,AI 助手可以调用 `generate_card_deck` 工具生成卡牌组。 +通过 MCP 协议,AI 助手可以调用以下工具: -### 工具参数 +### 快捷工具 + +#### `generate_card_deck` - 一站式生成卡牌组 + +快速生成完整的卡牌组,包括 Markdown 文件、CSV 数据文件和组件配置。 | 参数 | 类型 | 必需 | 说明 | |------|------|------|------| @@ -94,8 +98,41 @@ ttrpg mcp generate-card-deck \ | `deck_config` | object | ✗ | md-deck 组件配置 | | `description` | string | ✗ | 卡牌组描述 | -### card_template 结构 +### 核心工具 +#### `deck_frontmatter_read` - 读取 CSV frontmatter + +读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)。 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `csv_file` | string | ✓ | CSV 文件路径 | + +**返回示例:** +```json +{ + "fields": [ + { "name": "name", "description": "卡牌名称" }, + { "name": "type", "description": "卡牌类型" } + ], + "deck": { + "size": "54x86", + "grid": "5x8" + } +} +``` + +#### `deck_frontmatter_write` - 写入 CSV frontmatter + +写入或更新 CSV 文件的 frontmatter(模板定义和 deck 配置)。 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `csv_file` | string | ✓ | CSV 文件路径 | +| `frontmatter` | object | ✓ | 要写入的 frontmatter 数据 | +| `merge` | boolean | ✗ | 是否合并现有 frontmatter(默认 true) | + +**frontmatter 结构:** ```json { "fields": [ @@ -105,84 +142,171 @@ ttrpg mcp generate-card-deck \ "examples": ["示例值 1", "示例值 2"] } ], - "examples": [ - { - "字段名称": "值 1", - "字段名称 2": "值 2" - } - ] -} -``` - -### deck_config 结构 - -```json -{ - "size": "54x86", - "grid": "5x8", - "bleed": 1, - "padding": 2, - "shape": "rectangle", - "layers": "name:1,2-3,12 desc:1,4-8,10", - "back_layers": "back:1,2-8,12" -} -``` - -### 使用示例(AI 助手) - -**用户请求:** -> 帮我生成一个魔法物品卡牌组,包含 15 张卡牌,字段有名称、稀有度、效果描述 - -**AI 助手调用工具:** -```json -{ - "deck_name": "魔法物品", - "output_dir": "./content", - "card_count": 15, - "card_template": { - "fields": [ - { - "name": "name", - "description": "物品名称", - "examples": ["火球术卷轴", "治疗药水", "隐形斗篷"] - }, - { - "name": "rarity", - "description": "稀有度", - "examples": ["稀有", "普通", "珍贵"] - }, - { - "name": "effect", - "description": "效果描述", - "examples": ["造成 5d6 火焰伤害", "恢复 2d4+2 生命值", "隐身 1 小时"] - } - ] + "deck": { + "size": "54x86", + "grid": "5x8", + "bleed": 1, + "padding": 2, + "shape": "rectangle", + "layers": "name:1,2-3,12", + "back_layers": "back:1,2-8,12" } } ``` -**工具返回:** -- 生成的 Markdown 文件路径 -- 生成的 CSV 文件路径 -- `:md-deck` 组件代码 +#### `deck_card_crud` - 卡牌 CRUD 操作 -## 输出文件 +卡牌的创建、读取、更新、删除操作,支持批量操作。 -运行工具后会生成: +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `csv_file` | string | ✓ | CSV 文件路径 | +| `action` | string | ✓ | 操作类型:`create` \| `read` \| `update` \| `delete` | +| `cards` | object\|array | ✗ | 卡牌数据(单张或数组) | +| `label` | string\|array | ✗ | 要操作的卡牌 label(用于 read/update/delete) | -1. **Markdown 文件** (`{deck_name}.md`) - - 包含卡牌组标题和描述 - - 嵌入 `:md-deck` 组件代码 - - 使用说明 +**卡牌数据结构:** +```json +{ + "label": "1", + "name": "火球术卷轴", + "type": "法术", + "cost": "3", + "description": "造成 5d6 火焰伤害" +} +``` -2. **CSV 文件** (`{deck_name}.csv`) - - 包含所有卡牌数据 - - 支持 `{{字段名}}` 变量语法 - - 可使用 front matter 添加共享属性 +**使用示例:** -3. **组件代码** - - 可直接插入任何 Markdown 文件 - - 格式:`:md-deck[./xxx.csv]{size="54x86" grid="5x8" ...}` +```json +// 创建单张卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "create", + "cards": { + "label": "1", + "name": "火球术卷轴", + "type": "法术", + "cost": "3", + "description": "造成 5d6 火焰伤害" + } +} + +// 批量创建卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "create", + "cards": [ + { "name": "火球术卷轴", "type": "法术", "cost": "3" }, + { "name": "治疗药水", "type": "物品", "cost": "2" } + ] +} + +// 读取所有卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "read" +} + +// 读取指定卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "read", + "label": ["1", "2"] +} + +// 更新卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "update", + "cards": { "label": "1", "cost": "4" } +} + +// 删除卡牌 +{ + "csv_file": "./content/magic-items.csv", + "action": "delete", + "label": ["1", "2"] +} +``` + +#### `deck_ensure_preview` - 确保 Markdown 预览文件存在 + +确保 CSV 对应的 Markdown 预览文件存在,如果不存在则创建。 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `csv_file` | string | ✓ | CSV 文件路径 | +| `md_file` | string | ✗ | Markdown 文件路径(可选,默认与 CSV 同名) | +| `title` | string | ✗ | 标题(可选,默认从 CSV 文件名推断) | +| `description` | string | ✗ | 描述(可选) | + +## CSV 文件格式 + +CSV 文件使用 YAML frontmatter 定义模板和配置: + +```csv +--- +fields: + - name: name + description: 卡牌名称 + - name: type + description: 卡牌类型 + examples: [物品,法术,生物] + - name: cost + description: 费用 + - name: description + description: 效果描述 +deck: + size: 54x86 + grid: 5x8 + bleed: 1 + padding: 2 +--- +label,name,type,cost,description +1,火球术卷轴,法术,3,造成 5d6 火焰伤害 +2,治疗药水,物品,2,恢复 2d4+2 生命值 +``` + +### Frontmatter 说明 + +- `fields`: 字段定义列表 + - `name`: 字段名称(英文,用于 CSV 列名) + - `description`: 字段描述 + - `examples`: 示例值列表(可选) +- `deck`: Deck 配置 + - `size`: 卡牌尺寸,格式 "宽 x 高"(单位 mm) + - `grid`: 网格布局,格式 "列 x 行" + - `bleed`: 出血边距(mm) + - `padding`: 内边距(mm) + - `shape`: 卡牌形状(rectangle, circle, hex, diamond) + - `layers`: 正面图层配置 + - `back_layers`: 背面图层配置 + +### CSV 数据说明 + +- `label`: 卡牌标签(唯一标识,用于查找和修改) +- 其他列:由 frontmatter 中的 `fields` 定义 +- `body`: 卡牌 body 内容(可选,支持 `{{字段名}}` 语法) + +## 工作流示例 + +### 创建新卡牌组 + +1. **定义模板**:调用 `deck_frontmatter_write` 创建 CSV 和 frontmatter +2. **创建预览**:调用 `deck_ensure_preview` 创建 Markdown 预览文件 +3. **添加卡牌**:调用 `deck_card_crud`(action=create)添加卡牌 + +### 修改现有卡牌组 + +1. **读取模板**:调用 `deck_frontmatter_read` 获取当前配置 +2. **读取卡牌**:调用 `deck_card_crud`(action=read)获取卡牌数据 +3. **修改卡牌**:调用 `deck_card_crud`(action=update)更新卡牌 +4. **更新预览**:调用 `deck_ensure_preview` 更新 Markdown 文件 + +### 快捷生成 + +直接调用 `generate_card_deck` 一站式生成完整卡牌组。 ## 与 TTRPG Tools 集成 @@ -213,7 +337,13 @@ src/cli/ │ ├── compile.ts │ └── mcp.ts # MCP 命令入口 ├── tools/ -│ └── generate-card-deck.ts # 卡牌生成工具 +│ ├── frontmatter/ +│ │ ├── read-frontmatter.ts +│ │ └── write-frontmatter.ts +│ ├── card/ +│ │ └── card-crud.ts +│ ├── ensure-deck-preview.ts +│ └── generate-card-deck.ts └── index.ts ``` diff --git a/package-lock.json b/package-lock.json index 386cc03..f014fab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "chokidar": "^5.0.0", "commander": "^14.0.3", "csv-parse": "^6.1.0", + "csv-stringify": "^6.7.0", "js-yaml": "^4.1.1", "marked": "^17.0.3", "marked-alert": "^2.1.2", @@ -4828,6 +4829,12 @@ "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", "license": "MIT" }, + "node_modules/csv-stringify": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz", + "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==", + "license": "MIT" + }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", diff --git a/package.json b/package.json index e477fd0..7ecfc1f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "chokidar": "^5.0.0", "commander": "^14.0.3", "csv-parse": "^6.1.0", + "csv-stringify": "^6.7.0", "js-yaml": "^4.1.1", "marked": "^17.0.3", "marked-alert": "^2.1.2", diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index a371fdc..c8596d1 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -1,9 +1,13 @@ import { Command } from 'commander'; import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js'; +import { readFrontmatter, type ReadFrontmatterParams, type DeckFrontmatter } from '../tools/frontmatter/read-frontmatter.js'; +import { writeFrontmatter, type WriteFrontmatterParams } from '../tools/frontmatter/write-frontmatter.js'; +import { cardCrud, type CardCrudParams, type CardData } from '../tools/card/card-crud.js'; +import { ensureDeckPreview, type EnsureDeckPreviewParams } from '../tools/ensure-deck-preview.js'; /** * MCP 服务器命令 - * + * * 提供 MCP (Model Context Protocol) 服务器功能,用于与 AI 助手集成 */ @@ -70,7 +74,7 @@ async function mcpServeAction(host: string, options: MCPOptions) { tools: [ { name: 'generate_card_deck', - description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置', + description: '生成 TTRPG 卡牌组内容,包括 Markdown 介绍文件、CSV 数据文件和 md-deck 组件配置(一站式快捷工具)', inputSchema: { type: 'object', properties: { @@ -173,6 +177,129 @@ async function mcpServeAction(host: string, options: MCPOptions) { required: ['deck_name', 'output_dir'], }, }, + { + name: 'deck_frontmatter_read', + description: '读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)', + inputSchema: { + type: 'object', + properties: { + csv_file: { + type: 'string', + description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)', + }, + }, + required: ['csv_file'], + }, + }, + { + name: 'deck_frontmatter_write', + description: '写入/更新 CSV 文件的 frontmatter(模板定义和 deck 配置)', + inputSchema: { + type: 'object', + properties: { + csv_file: { + type: 'string', + description: 'CSV 文件路径', + }, + frontmatter: { + type: 'object', + description: '要写入的 frontmatter 数据', + properties: { + fields: { + type: 'array', + description: '字段定义列表', + items: { + type: 'object', + properties: { + name: { type: 'string', description: '字段名称' }, + description: { type: 'string', description: '字段描述' }, + examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' }, + }, + required: ['name'], + }, + }, + deck: { + type: 'object', + description: 'Deck 配置', + properties: { + size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' }, + grid: { type: 'string', description: '网格布局,格式 "列 x 行"' }, + bleed: { type: 'number', description: '出血边距(mm)' }, + padding: { type: 'number', description: '内边距(mm)' }, + shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] }, + layers: { type: 'string', description: '正面图层配置' }, + back_layers: { type: 'string', description: '背面图层配置' }, + }, + }, + }, + }, + merge: { + type: 'boolean', + description: '是否合并现有 frontmatter(默认 true)', + default: true, + }, + }, + required: ['csv_file', 'frontmatter'], + }, + }, + { + name: 'deck_card_crud', + description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作', + inputSchema: { + type: 'object', + properties: { + csv_file: { + type: 'string', + description: 'CSV 文件路径', + }, + action: { + type: 'string', + description: '操作类型', + enum: ['create', 'read', 'update', 'delete'], + }, + cards: { + type: ['array', 'object'], + description: '卡牌数据(单张或数组)', + items: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + label: { + type: ['string', 'array'], + description: '要操作的卡牌 label(用于 read/update/delete)', + items: { type: 'string' }, + }, + }, + required: ['csv_file', 'action'], + }, + }, + { + name: 'deck_ensure_preview', + description: '确保 CSV 对应的 Markdown 预览文件存在', + inputSchema: { + type: 'object', + properties: { + csv_file: { + type: 'string', + description: 'CSV 文件路径', + }, + md_file: { + type: 'string', + description: 'Markdown 文件路径(可选,默认与 CSV 同名)', + }, + title: { + type: 'string', + description: '标题(可选,默认从 CSV 文件名推断)', + }, + description: { + type: 'string', + description: '描述(可选)', + }, + }, + required: ['csv_file'], + }, + }, ], }; }); @@ -181,60 +308,142 @@ async function mcpServeAction(host: string, options: MCPOptions) { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; - if (name === 'generate_card_deck') { - try { - const params = args as unknown as GenerateCardDeckParams; + try { + switch (name) { + case 'generate_card_deck': { + const params = args as unknown as GenerateCardDeckParams; + + // 验证必需参数 + if (!params.deck_name || !params.output_dir) { + return { + content: [ + { + type: 'text', + text: '错误:缺少必需参数 deck_name 或 output_dir', + }, + ], + isError: true, + }; + } + + // 生成卡牌组(使用当前工作目录) + const result = generateCardDeck(params); - // 验证必需参数 - if (!params.deck_name || !params.output_dir) { return { content: [ { type: 'text', - text: '错误:缺少必需参数 deck_name 或 output_dir', + text: result.message, + }, + { + type: 'text', + text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``, }, ], - isError: true, }; } - // 生成卡牌组(使用当前工作目录) - const result = generateCardDeck(params); + case 'deck_frontmatter_read': { + const params = args as unknown as ReadFrontmatterParams; - return { - content: [ - { - type: 'text', - text: result.message, - }, - { - type: 'text', - text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``, - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `生成失败:${error instanceof Error ? error.message : '未知错误'}`, - }, - ], - isError: true, - }; + if (!params.csv_file) { + return { + content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }], + isError: true, + }; + } + + const result = readFrontmatter(params); + + return { + content: [ + { + type: 'text', + text: result.message, + }, + ...(result.frontmatter ? [{ + type: 'text', + text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``, + }] : []), + ], + }; + } + + case 'deck_frontmatter_write': { + const params = args as unknown as WriteFrontmatterParams; + + if (!params.csv_file || !params.frontmatter) { + return { + content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 frontmatter' }], + isError: true, + }; + } + + const result = writeFrontmatter(params); + + return { + content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }], + isError: !result.success, + }; + } + + case 'deck_card_crud': { + const params = args as unknown as CardCrudParams; + + if (!params.csv_file || !params.action) { + return { + content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 action' }], + isError: true, + }; + } + + const result = cardCrud(params); + + return { + content: [ + { + type: 'text', + text: result.success ? `✅ ${result.message}` : `❌ ${result.message}`, + }, + ...(result.cards && result.cards.length > 0 ? [{ + type: 'text', + text: `\n## 卡牌数据\n\n\`\`\`json\n${JSON.stringify(result.cards, null, 2)}\n\`\`\``, + }] : []), + ], + isError: !result.success, + }; + } + + case 'deck_ensure_preview': { + const params = args as unknown as EnsureDeckPreviewParams; + + if (!params.csv_file) { + return { + content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }], + isError: true, + }; + } + + const result = ensureDeckPreview(params); + + return { + content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }], + isError: !result.success, + }; + } + + default: + return { + content: [{ type: 'text', text: `未知工具:${name}` }], + isError: true, + }; } + } catch (error) { + return { + content: [{ type: 'text', text: `工具调用失败:${error instanceof Error ? error.message : '未知错误'}` }], + isError: true, + }; } - - return { - content: [ - { - type: 'text', - text: `未知工具:${name}`, - }, - ], - isError: true, - }; }); // 启动服务器 diff --git a/src/cli/tools/card/card-crud.ts b/src/cli/tools/card/card-crud.ts new file mode 100644 index 0000000..01ffc5c --- /dev/null +++ b/src/cli/tools/card/card-crud.ts @@ -0,0 +1,301 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { parse } from 'csv-parse/browser/esm/sync'; +import { stringify } from 'csv-stringify/browser/esm/sync'; +import yaml from 'js-yaml'; +import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js'; + +/** + * 卡牌数据 + */ +export interface CardData { + label?: string; + [key: string]: string | undefined; +} + +/** + * 卡牌 CRUD 参数 + */ +export interface CardCrudParams { + /** + * CSV 文件路径 + */ + csv_file: string; + /** + * 操作类型 + */ + action: 'create' | 'read' | 'update' | 'delete'; + /** + * 卡牌数据(单张或数组) + */ + cards?: CardData | CardData[]; + /** + * 要读取/更新的卡牌 label(用于 read/update/delete) + */ + label?: string | string[]; +} + +/** + * 卡牌 CRUD 结果 + */ +export interface CardCrudResult { + success: boolean; + message: string; + cards?: CardData[]; + count?: number; +} + +/** + * 解析 CSV 文件的 frontmatter + */ +function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { + const parts = content.trim().split(/(?:^|\n)---\s*\n/g); + + if (parts.length !== 3 || parts[0] !== '') { + return { csvContent: content }; + } + + try { + const frontmatterStr = parts[1].trim(); + const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; + const csvContent = parts.slice(2).join('---\n').trimStart(); + return { frontmatter, csvContent }; + } catch (error) { + console.warn('Failed to parse front matter:', error); + return { csvContent: content }; + } +} + +/** + * 序列化 frontmatter 为 YAML 字符串 + */ +function serializeFrontMatter(frontmatter: DeckFrontmatter): string { + const yamlStr = yaml.dump(frontmatter, { + indent: 2, + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false + }); + return `---\n${yamlStr}---\n`; +} + +/** + * 加载 CSV 数据(包含 frontmatter) + */ +function loadCSVWithFrontmatter(filePath: string): { + frontmatter?: DeckFrontmatter; + records: CardData[]; + headers: string[]; +} { + const content = readFileSync(filePath, 'utf-8'); + const { frontmatter, csvContent } = parseFrontMatter(content); + + const records = parse(csvContent, { + columns: true, + comment: '#', + trim: true, + skipEmptyLines: true + }) as CardData[]; + + // 获取表头 + const firstLine = csvContent.split('\n')[0]; + const headers = firstLine.split(',').map(h => h.trim()); + + return { frontmatter, records, headers }; +} + +/** + * 保存 CSV 数据(包含 frontmatter) + */ +function saveCSVWithFrontmatter( + filePath: string, + frontmatter: DeckFrontmatter | undefined, + records: CardData[], + headers?: string[] +): void { + // 序列化 frontmatter + const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ''; + + // 确定表头 + if (!headers || headers.length === 0) { + // 从 records 和 frontmatter.fields 推断表头 + headers = ['label']; + if (frontmatter?.fields && Array.isArray(frontmatter.fields)) { + for (const field of frontmatter.fields) { + if (field.name && typeof field.name === 'string') { + headers.push(field.name); + } + } + } + headers.push('body'); + } + + // 确保所有 record 都有 headers 中的列 + for (const record of records) { + for (const header of headers) { + if (!(header in record)) { + record[header] = ''; + } + } + } + + // 序列化 CSV + const csvContent = stringify(records, { + header: true, + columns: headers + }); + + // 写入文件 + writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8'); +} + +/** + * 生成下一个 label + */ +function generateNextLabel(records: CardData[]): string { + const maxLabel = records.reduce((max, record) => { + const label = record.label ? parseInt(record.label, 10) : 0; + return label > max ? label : max; + }, 0); + return (maxLabel + 1).toString(); +} + +/** + * 卡牌 CRUD 操作 + */ +export function cardCrud(params: CardCrudParams): CardCrudResult { + const { csv_file, action, cards, label } = params; + + // 检查文件是否存在(create 操作可以不存在) + if (action !== 'create' && !existsSync(csv_file)) { + return { + success: false, + message: `文件不存在:${csv_file}` + }; + } + + try { + let frontmatter: DeckFrontmatter | undefined; + let records: CardData[] = []; + let headers: string[] = []; + + // 加载现有数据 + if (existsSync(csv_file)) { + const data = loadCSVWithFrontmatter(csv_file); + frontmatter = data.frontmatter; + records = data.records; + headers = data.headers; + } + + // 执行操作 + switch (action) { + case 'create': { + const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); + for (const card of newCards) { + if (!card.label) { + card.label = generateNextLabel(records); + } + records.push(card); + } + saveCSVWithFrontmatter(csv_file, frontmatter, records, headers); + return { + success: true, + message: `成功创建 ${newCards.length} 张卡牌`, + cards: newCards, + count: newCards.length + }; + } + + case 'read': { + const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null); + let resultCards: CardData[]; + + if (labelsToRead && labelsToRead.length > 0) { + resultCards = records.filter(r => labelsToRead.includes(r.label || '')); + } else { + resultCards = records; + } + + return { + success: true, + message: `成功读取 ${resultCards.length} 张卡牌`, + cards: resultCards, + count: resultCards.length + }; + } + + case 'update': { + const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null); + const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); + let updatedCount = 0; + + if (labelsToUpdate && labelsToUpdate.length > 0) { + // 按 label 更新 + for (const updateCard of updateCards) { + const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length]; + const index = records.findIndex(r => r.label === targetLabel); + if (index !== -1) { + records[index] = { ...records[index], ...updateCard }; + updatedCount++; + } + } + } else { + // 按 cards 中的 label 更新 + for (const updateCard of updateCards) { + if (updateCard.label) { + const index = records.findIndex(r => r.label === updateCard.label); + if (index !== -1) { + records[index] = { ...records[index], ...updateCard }; + updatedCount++; + } + } + } + } + + saveCSVWithFrontmatter(csv_file, frontmatter, records, headers); + return { + success: true, + message: `成功更新 ${updatedCount} 张卡牌`, + cards: updateCards, + count: updatedCount + }; + } + + case 'delete': { + const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null); + let deletedCount = 0; + + if (labelsToDelete && labelsToDelete.length > 0) { + const beforeCount = records.length; + records = records.filter(r => !labelsToDelete.includes(r.label || '')); + deletedCount = beforeCount - records.length; + } else if (cards) { + const cardsToDelete = Array.isArray(cards) ? cards : [cards]; + const beforeCount = records.length; + records = records.filter(r => + !cardsToDelete.some(c => c.label && r.label === c.label) + ); + deletedCount = beforeCount - records.length; + } + + saveCSVWithFrontmatter(csv_file, frontmatter, records, headers); + return { + success: true, + message: `成功删除 ${deletedCount} 张卡牌`, + count: deletedCount + }; + } + + default: + return { + success: false, + message: `未知操作:${action}` + }; + } + } catch (error) { + return { + success: false, + message: `操作失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } +} diff --git a/src/cli/tools/ensure-deck-preview.ts b/src/cli/tools/ensure-deck-preview.ts new file mode 100644 index 0000000..d2c9478 --- /dev/null +++ b/src/cli/tools/ensure-deck-preview.ts @@ -0,0 +1,217 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { dirname, join, basename, extname } from 'path'; +import yaml from 'js-yaml'; +import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; + +/** + * 确保 Deck 预览文件的参数 + */ +export interface EnsureDeckPreviewParams { + /** + * CSV 文件路径 + */ + csv_file: string; + /** + * Markdown 文件路径(可选,默认与 CSV 同名) + */ + md_file?: string; + /** + * 标题(可选,默认从 CSV 文件名推断) + */ + title?: string; + /** + * 描述(可选) + */ + description?: string; +} + +/** + * 确保 Deck 预览文件的结果 + */ +export interface EnsureDeckPreviewResult { + success: boolean; + message: string; + md_file?: string; + created?: boolean; +} + +/** + * 解析 CSV 文件的 frontmatter + */ +function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { + const parts = content.trim().split(/(?:^|\n)---\s*\n/g); + + if (parts.length !== 3 || parts[0] !== '') { + return { csvContent: content }; + } + + try { + const frontmatterStr = parts[1].trim(); + const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; + const csvContent = parts.slice(2).join('---\n').trimStart(); + return { frontmatter, csvContent }; + } catch (error) { + console.warn('Failed to parse front matter:', error); + return { csvContent: content }; + } +} + +/** + * 构建 md-deck 组件代码 + */ +function buildDeckComponent(csvPath: string, config?: DeckConfig): string { + const parts = [`:md-deck[${csvPath}]`]; + const attrs: string[] = []; + + if (config?.size) { + attrs.push(`size="${config.size}"`); + } + + if (config?.grid) { + attrs.push(`grid="${config.grid}"`); + } + + if (config?.bleed !== undefined && config.bleed !== 1) { + attrs.push(`bleed="${config.bleed}"`); + } + + if (config?.padding !== undefined && config.padding !== 2) { + attrs.push(`padding="${config.padding}"`); + } + + if (config?.shape && config.shape !== 'rectangle') { + attrs.push(`shape="${config.shape}"`); + } + + if (config?.layers) { + attrs.push(`layers="${config.layers}"`); + } + + if (config?.back_layers) { + attrs.push(`back-layers="${config.back_layers}"`); + } + + if (attrs.length > 0) { + parts.push(`{${attrs.join(' ')}}`); + } + + return parts.join(''); +} + +/** + * 确保 Markdown 预览文件存在 + */ +export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult { + const { csv_file, md_file, title, description } = params; + + // 检查 CSV 文件是否存在 + if (!existsSync(csv_file)) { + return { + success: false, + message: `CSV 文件不存在:${csv_file}` + }; + } + + // 确定 Markdown 文件路径 + let mdFilePath = md_file; + if (!mdFilePath) { + // 使用与 CSV 相同的路径和文件名,只是扩展名不同 + const dir = dirname(csv_file); + const name = basename(csv_file, extname(csv_file)); + mdFilePath = join(dir, `${name}.md`); + } + + const created = !existsSync(mdFilePath); + + // 如果文件已存在,检查是否已有 md-deck 组件 + if (!created) { + try { + const existingContent = readFileSync(mdFilePath, 'utf-8'); + // 如果已有 md-deck 组件引用该 CSV,直接返回 + if (existingContent.includes(`:md-deck[${csv_file}]`) || + existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) { + return { + success: true, + message: `预览文件 ${mdFilePath} 已存在`, + md_file: mdFilePath, + created: false + }; + } + } catch (error) { + // 读取失败,继续创建 + } + } + + // 读取 CSV 的 frontmatter 获取配置 + let deckConfig: DeckConfig | undefined; + try { + const csvContent = readFileSync(csv_file, 'utf-8'); + const { frontmatter } = parseFrontMatter(csvContent); + deckConfig = frontmatter?.deck; + } catch (error) { + // 忽略错误,使用默认配置 + } + + // 确定标题 + let deckTitle = title; + if (!deckTitle) { + // 从 CSV 文件名推断 + const name = basename(csv_file, extname(csv_file)); + deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + + // 构建 md-deck 组件代码(使用相对路径) + const mdDir = dirname(mdFilePath); + const csvDir = dirname(csv_file); + let relativeCsvPath: string; + + if (mdDir === csvDir) { + relativeCsvPath = `./${basename(csv_file)}`; + } else { + relativeCsvPath = `./${basename(csv_file)}`; + } + + const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig); + + // 生成 Markdown 内容 + const mdLines: string[] = []; + + mdLines.push(`# ${deckTitle}`); + mdLines.push(''); + + if (description) { + mdLines.push(description); + mdLines.push(''); + } + + mdLines.push('## 卡牌预览'); + mdLines.push(''); + mdLines.push(deckComponent); + mdLines.push(''); + + mdLines.push('## 使用说明'); + mdLines.push(''); + mdLines.push('- 点击卡牌可以查看详情'); + mdLines.push('- 使用右上角的按钮可以随机抽取卡牌'); + mdLines.push('- 可以通过编辑面板调整卡牌样式和布局'); + mdLines.push(''); + + // 写入文件 + try { + writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8'); + + return { + success: true, + message: created + ? `创建预览文件 ${mdFilePath}` + : `更新预览文件 ${mdFilePath}`, + md_file: mdFilePath, + created + }; + } catch (error) { + return { + success: false, + message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } +} diff --git a/src/cli/tools/frontmatter/read-frontmatter.ts b/src/cli/tools/frontmatter/read-frontmatter.ts new file mode 100644 index 0000000..4f405dc --- /dev/null +++ b/src/cli/tools/frontmatter/read-frontmatter.ts @@ -0,0 +1,127 @@ +import { readFileSync, existsSync } from 'fs'; +import yaml from 'js-yaml'; + +/** + * 读取 CSV frontmatter 的参数 + */ +export interface ReadFrontmatterParams { + /** + * CSV 文件路径(相对路径相对于 MCP 服务器工作目录) + */ + csv_file: string; +} + +/** + * Frontmatter 数据结构 + */ +export interface DeckFrontmatter { + /** + * 字段定义 + */ + fields?: CardField[]; + /** + * Deck 配置 + */ + deck?: DeckConfig; + /** + * 其他自定义属性 + */ + [key: string]: unknown; +} + +/** + * 卡牌字段定义 + */ +export interface CardField { + name: string; + description?: string; + examples?: string[]; + [key: string]: unknown; +} + +/** + * Deck 配置 + */ +export interface DeckConfig { + size?: string; + grid?: string; + bleed?: number; + padding?: number; + shape?: 'rectangle' | 'circle' | 'hex' | 'diamond'; + layers?: string; + back_layers?: string; + [key: string]: unknown; +} + +/** + * 读取 CSV frontmatter 的结果 + */ +export interface ReadFrontmatterResult { + success: boolean; + frontmatter?: DeckFrontmatter; + message: string; +} + +/** + * 解析 CSV 文件的 frontmatter + */ +function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { + const parts = content.trim().split(/(?:^|\n)---\s*\n/g); + + // 至少需要三个部分:空字符串、front matter、CSV 内容 + if (parts.length !== 3 || parts[0] !== '') { + return { csvContent: content }; + } + + try { + const frontmatterStr = parts[1].trim(); + const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; + const csvContent = parts.slice(2).join('---\n').trimStart(); + return { frontmatter, csvContent }; + } catch (error) { + console.warn('Failed to parse front matter:', error); + return { csvContent: content }; + } +} + +/** + * 读取 CSV 文件的 frontmatter + */ +export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult { + const { csv_file } = params; + + // 检查文件是否存在 + if (!existsSync(csv_file)) { + return { + success: false, + message: `文件不存在:${csv_file}` + }; + } + + try { + // 读取文件内容 + const content = readFileSync(csv_file, 'utf-8'); + + // 解析 frontmatter + const { frontmatter } = parseFrontMatter(content); + + if (!frontmatter) { + return { + success: true, + frontmatter: {}, + message: `文件 ${csv_file} 没有 frontmatter,返回空对象` + }; + } + + return { + success: true, + frontmatter, + message: `成功读取 ${csv_file} 的 frontmatter` + }; + } catch (error) { + return { + success: false, + message: `读取失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } +} diff --git a/src/cli/tools/frontmatter/write-frontmatter.ts b/src/cli/tools/frontmatter/write-frontmatter.ts new file mode 100644 index 0000000..464cbd6 --- /dev/null +++ b/src/cli/tools/frontmatter/write-frontmatter.ts @@ -0,0 +1,131 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import yaml from 'js-yaml'; +import type { DeckFrontmatter } from './read-frontmatter.js'; + +/** + * 写入 CSV frontmatter 的参数 + */ +export interface WriteFrontmatterParams { + /** + * CSV 文件路径(相对路径相对于 MCP 服务器工作目录) + */ + csv_file: string; + /** + * 要写入的 frontmatter 数据 + */ + frontmatter: DeckFrontmatter; + /** + * 是否合并现有 frontmatter(默认 true) + */ + merge?: boolean; +} + +/** + * 写入 CSV frontmatter 的结果 + */ +export interface WriteFrontmatterResult { + success: boolean; + message: string; + csv_file?: string; +} + +/** + * 解析 CSV 文件的 frontmatter + */ +function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } { + const parts = content.trim().split(/(?:^|\n)---\s*\n/g); + + // 至少需要三个部分:空字符串、front matter、CSV 内容 + if (parts.length !== 3 || parts[0] !== '') { + return { csvContent: content }; + } + + try { + const frontmatterStr = parts[1].trim(); + const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined; + const csvContent = parts.slice(2).join('---\n').trimStart(); + return { frontmatter, csvContent }; + } catch (error) { + console.warn('Failed to parse front matter:', error); + return { csvContent: content }; + } +} + +/** + * 序列化 frontmatter 为 YAML 字符串 + */ +function serializeFrontMatter(frontmatter: DeckFrontmatter): string { + const yamlStr = yaml.dump(frontmatter, { + indent: 2, + lineWidth: -1, // 不限制行宽 + noRefs: true, // 不使用引用 + quotingType: '"', + forceQuotes: false + }); + return `---\n${yamlStr}---\n`; +} + +/** + * 写入/更新 CSV 文件的 frontmatter + */ +export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult { + const { csv_file, frontmatter, merge = true } = params; + + let csvContent = ''; + let existingFrontmatter: DeckFrontmatter | undefined; + + // 如果文件存在且需要合并,先读取现有内容 + if (merge && existsSync(csv_file)) { + try { + const content = readFileSync(csv_file, 'utf-8'); + const result = parseFrontMatter(content); + existingFrontmatter = result.frontmatter; + csvContent = result.csvContent; + } catch (error) { + return { + success: false, + message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } + } + + // 合并或替换 frontmatter + const finalFrontmatter = merge && existingFrontmatter + ? { ...existingFrontmatter, ...frontmatter } + : frontmatter; + + // 序列化 frontmatter + const frontmatterStr = serializeFrontMatter(finalFrontmatter); + + // 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容 + if (!csvContent.trim()) { + // 从 frontmatter 推断 CSV 表头 + const headers = ['label']; + if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) { + for (const field of finalFrontmatter.fields) { + if (field.name && typeof field.name === 'string') { + headers.push(field.name); + } + } + } + headers.push('body'); + csvContent = headers.join(',') + '\n'; + } + + // 写入文件 + try { + const fullContent = frontmatterStr + csvContent; + writeFileSync(csv_file, fullContent, 'utf-8'); + + return { + success: true, + message: `成功写入 frontmatter 到 ${csv_file}`, + csv_file + }; + } catch (error) { + return { + success: false, + message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` + }; + } +}