import { existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; export type Grade = "小班" | "中班" | "大班"; export type ClassType = { id: number; name: string; enabled: boolean; createdAt: string; updatedAt: string; }; export type AppSettings = { ttsVoice: string; ttsRate: string; scorePerCorrect: number; scorePerWrong: number; autoSpeak: boolean; wordDisplayMode: "uppercase" | "capitalize" | "input"; pkWordCount: number; pkOptionCount: number; }; export type WordRecord = { id: number; grade: Grade; classTypeId: number; classTypeName: string; english: string; chinese: string; image: string; audio: string; enabled: boolean; createdAt: string; updatedAt: string; }; export type WordInput = { grade: Grade; classTypeId: number; english: string; chinese: string; image: string; audio?: string; enabled: boolean; }; type DbWordRow = { id: number; grade: Grade; class_type_id: number; class_type_name: string; english: string; chinese: string | null; image: string | null; audio: string | null; enabled: 0 | 1; created_at: string; updated_at: string; }; type DbClassTypeRow = { id: number; name: string; enabled: 0 | 1; created_at: string; updated_at: string; }; const dbDirectory = path.join(process.cwd(), "data"); const dbPath = path.join(dbDirectory, "english-pk.sqlite"); let db: DatabaseSync | null = null; const defaultClassTypes = ["基础班", "提高班", "复习班"]; const defaultWords = [ ["中班", "基础班", "box", "盒子", "📦"], ["中班", "基础班", "fox", "狐狸", "🦊"], ["中班", "基础班", "ox", "公牛", "🐂"], ["中班", "基础班", "ax", "斧头", "🪓"], ["中班", "提高班", "yarn", "毛线", "🧶"], ["中班", "提高班", "yo-yo", "悠悠球", "🪀"], ["中班", "提高班", "yam", "山药", "🍠"], ["中班", "提高班", "yellow", "黄色", "💛"], ["中班", "复习班", "zoo", "动物园", "🏞️"], ["中班", "复习班", "zero", "零", "0️⃣"], ["中班", "复习班", "zebra", "斑马", "🦓"], ["中班", "复习班", "zipper", "拉链", "🧥"], ] satisfies Array<[Grade, string, string, string, string]>; function mapClassType(row: DbClassTypeRow): ClassType { return { id: row.id, name: row.name, enabled: Boolean(row.enabled), createdAt: row.created_at, updatedAt: row.updated_at, }; } function mapWord(row: DbWordRow): WordRecord { return { id: row.id, grade: row.grade, classTypeId: row.class_type_id, classTypeName: row.class_type_name, english: row.english, chinese: row.chinese ?? "", image: row.image ?? "", audio: row.audio ?? "", enabled: Boolean(row.enabled), createdAt: row.created_at, updatedAt: row.updated_at, }; } function getDatabase() { if (db) return db; if (!existsSync(dbDirectory)) { mkdirSync(dbDirectory, { recursive: true }); } db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); initializeDatabase(db); return db; } function initializeDatabase(database: DatabaseSync) { database.exec(` CREATE TABLE IF NOT EXISTS class_types ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS words ( id INTEGER PRIMARY KEY AUTOINCREMENT, grade TEXT NOT NULL, class_type_id INTEGER NOT NULL, english TEXT NOT NULL, chinese TEXT NOT NULL DEFAULT '', image TEXT NOT NULL DEFAULT '', audio TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (class_type_id) REFERENCES class_types(id) ON DELETE RESTRICT, UNIQUE (grade, class_type_id, english) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>; if (!wordColumns.some((column) => column.name === "image")) { database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';"); } if (!wordColumns.some((column) => column.name === "chinese")) { database.exec("ALTER TABLE words ADD COLUMN chinese TEXT NOT NULL DEFAULT '';"); } if (!wordColumns.some((column) => column.name === "audio")) { database.exec("ALTER TABLE words ADD COLUMN audio TEXT NOT NULL DEFAULT '';"); } const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number }; if (classTypeCount.count === 0) { const insertClassType = database.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)"); for (const name of defaultClassTypes) { insertClassType.run(name); } } const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number }; if (wordCount.count === 0) { const findClassType = database.prepare("SELECT id FROM class_types WHERE name = ?"); const insertWord = database.prepare( "INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)", ); for (const [grade, classTypeName, english, chinese, image] of defaultWords) { const classType = findClassType.get(classTypeName) as { id: number } | undefined; if (classType) { insertWord.run(grade, classType.id, english, chinese, image); } } } const defaultIconUpdates = database.prepare( "UPDATE words SET image = ? WHERE english = ? AND (image IS NULL OR image = '')", ); for (const [, , english, , image] of defaultWords) { defaultIconUpdates.run(image, english); } const defaultChineseUpdates = database.prepare( "UPDATE words SET chinese = ? WHERE english = ? AND (chinese IS NULL OR chinese = '')", ); for (const [, , english, chinese] of defaultWords) { defaultChineseUpdates.run(chinese, english); } const defaultSettings: AppSettings = { ttsVoice: "en-US-EmmaMultilingualNeural", ttsRate: "-10%", scorePerCorrect: 10, scorePerWrong: 0, autoSpeak: true, wordDisplayMode: "uppercase", pkWordCount: 0, pkOptionCount: 4, }; const insertSetting = database.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)"); for (const [key, value] of Object.entries(defaultSettings)) { insertSetting.run(key, String(value)); } } export function listClassTypes(includeDisabled = true) { const database = getDatabase(); const sql = includeDisabled ? "SELECT * FROM class_types ORDER BY id ASC" : "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC"; return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType); } export function createClassType(name: string) { const database = getDatabase(); const trimmedName = name.trim(); if (!trimmedName) { throw new Error("班级类型不能为空"); } const result = database .prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)") .run(trimmedName); return getClassType(Number(result.lastInsertRowid)); } export function updateClassType(id: number, input: { name: string; enabled: boolean }) { const database = getDatabase(); const trimmedName = input.name.trim(); if (!trimmedName) { throw new Error("班级类型不能为空"); } database .prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(trimmedName, input.enabled ? 1 : 0, id); return getClassType(id); } export function deleteClassType(id: number) { const database = getDatabase(); database.prepare("DELETE FROM class_types WHERE id = ?").run(id); } function getClassType(id: number) { const row = getDatabase().prepare("SELECT * FROM class_types WHERE id = ?").get(id) as | DbClassTypeRow | undefined; if (!row) return null; return mapClassType(row); } export function listWords(filters?: { grade?: string; classTypeId?: number; enabledOnly?: boolean }) { const database = getDatabase(); const clauses: string[] = []; const values: Array = []; if (filters?.grade) { clauses.push("words.grade = ?"); values.push(filters.grade); } if (filters?.classTypeId) { clauses.push("words.class_type_id = ?"); values.push(filters.classTypeId); } if (filters?.enabledOnly) { clauses.push("words.enabled = 1"); clauses.push("class_types.enabled = 1"); } const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; const rows = database .prepare( ` SELECT words.id, words.grade, words.class_type_id, class_types.name AS class_type_name, words.english, words.chinese, words.image, words.audio, words.enabled, words.created_at, words.updated_at FROM words JOIN class_types ON class_types.id = words.class_type_id ${where} ORDER BY words.id ASC `, ) .all(...values) as DbWordRow[]; return rows.map(mapWord); } export function findWord(id: number) { return getWord(id); } export function createWord(input: WordInput) { const database = getDatabase(); validateWordInput(input); const result = database .prepare("INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)") .run( input.grade, input.classTypeId, input.english.trim(), input.chinese.trim(), input.image.trim(), input.audio?.trim() ?? "", input.enabled ? 1 : 0, ); return getWord(Number(result.lastInsertRowid)); } export function updateWord(id: number, input: WordInput) { const database = getDatabase(); validateWordInput(input); const existing = getWord(id); const shouldClearAudio = existing && (existing.grade !== input.grade || existing.classTypeId !== input.classTypeId || existing.english !== input.english.trim()); database .prepare( "UPDATE words SET grade = ?, class_type_id = ?, english = ?, chinese = ?, image = ?, audio = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", ) .run( input.grade, input.classTypeId, input.english.trim(), input.chinese.trim(), input.image.trim(), shouldClearAudio ? "" : input.audio?.trim() ?? existing?.audio ?? "", input.enabled ? 1 : 0, id, ); return getWord(id); } export function deleteWord(id: number) { const database = getDatabase(); database.prepare("DELETE FROM words WHERE id = ?").run(id); } export function updateWordAudio(id: number, audio: string) { const database = getDatabase(); database .prepare("UPDATE words SET audio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(audio.trim(), id); return getWord(id); } function getWord(id: number) { return listWords().find((word) => word.id === id) ?? null; } function validateWordInput(input: WordInput) { if (!["小班", "中班", "大班"].includes(input.grade)) { throw new Error("年级不正确"); } if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) { throw new Error("班级类型不正确"); } if (!input.english.trim()) { throw new Error("题库英语不能为空"); } } export function getSettings(): AppSettings { const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{ key: string; value: string; }>; const settings = Object.fromEntries(rows.map((row) => [row.key, row.value])); return { ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural", ttsRate: settings.ttsRate || "-10%", scorePerCorrect: Number(settings.scorePerCorrect || 10), scorePerWrong: Number(settings.scorePerWrong || 0), autoSpeak: settings.autoSpeak !== "false", wordDisplayMode: parseWordDisplayMode(settings.wordDisplayMode), pkWordCount: Math.max(0, Number(settings.pkWordCount || 0)), pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount || 4)), }; } export function updateSettings(input: AppSettings) { const database = getDatabase(); const statement = database.prepare( "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", ); statement.run("ttsVoice", input.ttsVoice.trim() || "en-US-EmmaMultilingualNeural"); statement.run("ttsRate", input.ttsRate.trim() || "-10%"); statement.run("scorePerCorrect", String(Math.max(1, Number(input.scorePerCorrect) || 10))); statement.run("scorePerWrong", String(Math.max(0, Number(input.scorePerWrong) || 0))); statement.run("autoSpeak", String(Boolean(input.autoSpeak))); statement.run("wordDisplayMode", parseWordDisplayMode(input.wordDisplayMode)); statement.run("pkWordCount", String(Math.max(0, Math.floor(Number(input.pkWordCount) || 0)))); statement.run("pkOptionCount", String(normalizeOptionCount(Number(input.pkOptionCount) || 4))); return getSettings(); } function parseWordDisplayMode(value: unknown): AppSettings["wordDisplayMode"] { return value === "capitalize" || value === "input" || value === "uppercase" ? value : "uppercase"; } function normalizeOptionCount(value: number) { const count = Math.floor(value); if (!Number.isFinite(count)) return 4; if (count < 2) return 2; if (count > 8) return 8; return count % 2 === 0 ? count : count + 1; }