ttrpg-tools/src/cli/tools/card/card-crud.ts

302 lines
8.1 KiB
TypeScript
Raw Normal View History

2026-03-18 12:08:28 +08:00
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 : '未知错误'}`
};
}
}