Create deck-utils.ts and frontmatter/shared.ts to consolidate duplicated logic. Rename ensure-deck-preview.ts to preview-deck.ts.
290 lines
7.5 KiB
TypeScript
290 lines
7.5 KiB
TypeScript
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||
import { parse } from "csv-parse/browser/esm/sync";
|
||
import { stringify } from "csv-stringify/browser/esm/sync";
|
||
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
|
||
import {
|
||
parseFrontMatter,
|
||
serializeFrontMatter,
|
||
} from "../frontmatter/shared.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 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 : "未知错误"}`,
|
||
};
|
||
}
|
||
}
|