Files
englishPk/lib/db.ts
T
hanhan 17fc24edc7
Build Docker Package / docker-package (push) Successful in 6m34s
feat: add word import and class type display controls
2026-07-02 14:34:00 +08:00

714 lines
22 KiB
TypeScript

import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: 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;
};
export type WordImportInput = {
english: string;
chinese: string;
image: string;
};
export type WordImportResult = {
created: number;
excluded: number;
skipped: number;
errors: string[];
};
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;
grade: Grade;
name: string;
enabled: 0 | 1;
show_current_word: 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,
grade: row.grade,
name: row.name,
enabled: Boolean(row.enabled),
showCurrentWord: Boolean(row.show_current_word),
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,
grade TEXT NOT NULL DEFAULT '中班',
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
show_current_word INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (grade, name)
);
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
);
`);
migrateClassTypesByGrade(database);
migrateClassTypeDisplayOptions(database);
ensureWordClassTypeGradeConsistency(database);
cleanupUnreferencedDuplicatedCustomClassTypes(database);
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 (grade, name, enabled, show_current_word) VALUES (?, ?, 1, 1)");
for (const grade of ["小班", "中班", "大班"] satisfies Grade[]) {
for (const name of defaultClassTypes) {
insertClassType.run(grade, 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 grade = ? AND 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(grade, 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));
}
}
function migrateClassTypesByGrade(database: DatabaseSync) {
const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
if (classTypeColumns.some((column) => column.name === "grade")) {
return;
}
const oldClassTypes = database.prepare("SELECT * FROM class_types ORDER BY id ASC").all() as Array<{
id: number;
name: string;
enabled: 0 | 1;
created_at: string;
updated_at: string;
}>;
const wordGradeRows = database
.prepare("SELECT class_type_id, grade FROM words GROUP BY class_type_id, grade")
.all() as Array<{ class_type_id: number; grade: Grade }>;
const gradesByClassType = new Map<number, Grade[]>();
for (const row of wordGradeRows) {
const grades = gradesByClassType.get(row.class_type_id) ?? [];
if (!grades.includes(row.grade)) {
grades.push(row.grade);
}
gradesByClassType.set(row.class_type_id, grades);
}
database.exec(`
PRAGMA foreign_keys = OFF;
CREATE TABLE class_types_next (
id INTEGER PRIMARY KEY AUTOINCREMENT,
grade TEXT NOT NULL DEFAULT '中班',
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
show_current_word INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (grade, name)
);
`);
const insertWithId = database.prepare(
"INSERT INTO class_types_next (id, grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
);
const insertCopy = database.prepare(
"INSERT INTO class_types_next (grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
);
const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE class_type_id = ? AND grade = ?");
const allGrades = ["小班", "中班", "大班"] satisfies Grade[];
for (const classType of oldClassTypes) {
const usedGrades = gradesByClassType.get(classType.id) ?? [];
const targetGrades = defaultClassTypes.includes(classType.name)
? allGrades
: usedGrades.length > 0
? usedGrades
: (["中班"] satisfies Grade[]);
const primaryGrade = targetGrades.includes("中班") ? "中班" : targetGrades[0];
insertWithId.run(
classType.id,
primaryGrade,
classType.name,
classType.enabled,
classType.created_at,
classType.updated_at,
);
updateWords.run(classType.id, classType.id, primaryGrade);
for (const grade of targetGrades) {
if (grade === primaryGrade) continue;
const result = insertCopy.run(grade, classType.name, classType.enabled, classType.created_at, classType.updated_at);
updateWords.run(Number(result.lastInsertRowid), classType.id, grade);
}
}
database.exec(`
DROP TABLE class_types;
ALTER TABLE class_types_next RENAME TO class_types;
PRAGMA foreign_keys = ON;
`);
}
function migrateClassTypeDisplayOptions(database: DatabaseSync) {
const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
if (!classTypeColumns.some((column) => column.name === "show_current_word")) {
database.exec("ALTER TABLE class_types ADD COLUMN show_current_word INTEGER NOT NULL DEFAULT 1;");
}
}
function ensureWordClassTypeGradeConsistency(database: DatabaseSync) {
const mismatches = database
.prepare(
`
SELECT DISTINCT
words.grade,
words.class_type_id AS source_class_type_id,
class_types.name,
class_types.enabled
FROM words
JOIN class_types ON class_types.id = words.class_type_id
WHERE words.grade <> class_types.grade
`,
)
.all() as Array<{
grade: Grade;
source_class_type_id: number;
name: string;
enabled: 0 | 1;
}>;
if (mismatches.length === 0) return;
const findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND name = ?");
const insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled) VALUES (?, ?, ?)");
const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE grade = ? AND class_type_id = ?");
for (const mismatch of mismatches) {
const existing = findClassType.get(mismatch.grade, mismatch.name) as { id: number } | undefined;
const classTypeId =
existing?.id ??
Number(insertClassType.run(mismatch.grade, mismatch.name, mismatch.enabled).lastInsertRowid);
updateWords.run(classTypeId, mismatch.grade, mismatch.source_class_type_id);
}
}
function cleanupUnreferencedDuplicatedCustomClassTypes(database: DatabaseSync) {
const duplicates = database
.prepare(
`
SELECT class_types.id, class_types.name, COUNT(words.id) AS word_count
FROM class_types
LEFT JOIN words ON words.class_type_id = class_types.id
WHERE class_types.name NOT IN (${defaultClassTypes.map(() => "?").join(", ")})
AND class_types.name IN (
SELECT name
FROM class_types
WHERE name NOT IN (${defaultClassTypes.map(() => "?").join(", ")})
GROUP BY name
HAVING COUNT(*) > 1
)
GROUP BY class_types.id
`,
)
.all(...defaultClassTypes, ...defaultClassTypes) as Array<{ id: number; name: string; word_count: number }>;
const namesWithWords = new Set(duplicates.filter((row) => row.word_count > 0).map((row) => row.name));
if (namesWithWords.size === 0) return;
const deleteClassType = database.prepare("DELETE FROM class_types WHERE id = ?");
for (const row of duplicates) {
if (row.word_count === 0 && namesWithWords.has(row.name)) {
deleteClassType.run(row.id);
}
}
}
export function listClassTypes(filters?: { grade?: Grade; includeDisabled?: boolean }) {
const database = getDatabase();
const clauses: string[] = [];
const values: Array<string | number> = [];
if (filters?.grade) {
clauses.push("grade = ?");
values.push(filters.grade);
}
if (filters?.includeDisabled === false) {
clauses.push("enabled = 1");
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
return (database.prepare(`SELECT * FROM class_types ${where} ORDER BY grade ASC, id ASC`).all(...values) as DbClassTypeRow[]).map(mapClassType);
}
export function createClassType(grade: Grade, name: string, options?: { showCurrentWord?: boolean }) {
const database = getDatabase();
validateGrade(grade);
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error("班级类型不能为空");
}
const result = database
.prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, ?)")
.run(grade, trimmedName, options?.showCurrentWord === false ? 0 : 1);
return getClassType(Number(result.lastInsertRowid));
}
export function updateClassType(id: number, input: { name: string; enabled: boolean; showCurrentWord?: boolean }) {
const database = getDatabase();
const trimmedName = input.name.trim();
if (!trimmedName) {
throw new Error("班级类型不能为空");
}
database
.prepare("UPDATE class_types SET name = ?, enabled = ?, show_current_word = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(trimmedName, input.enabled ? 1 : 0, input.showCurrentWord === false ? 0 : 1, 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<string | number> = [];
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 importWords(grade: Grade, classTypeId: number, rows: WordImportInput[]): WordImportResult {
const database = getDatabase();
validateWordInput({
grade,
classTypeId,
english: "placeholder",
chinese: "",
image: "",
enabled: true,
});
if (!getClassType(classTypeId)) {
throw new Error("班级类型不存在");
}
const findExisting = database.prepare(
"SELECT id FROM words WHERE grade = ? AND class_type_id = ? AND english = ?",
);
const insertWord = database.prepare(
"INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, '', 1)",
);
const result: WordImportResult = {
created: 0,
excluded: 0,
skipped: 0,
errors: [],
};
database.exec("BEGIN IMMEDIATE");
try {
rows.forEach((row, index) => {
const rowNumber = index + 2;
const english = row.english.trim();
if (!english) {
result.skipped += 1;
result.errors.push(`第 ${rowNumber} 行缺少词库英语`);
return;
}
const chinese = row.chinese.trim();
const image = row.image.trim();
const existing = findExisting.get(grade, classTypeId, english) as { id: number } | undefined;
if (existing) {
result.excluded += 1;
} else {
insertWord.run(grade, classTypeId, english, chinese, image);
result.created += 1;
}
});
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return result;
}
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) {
validateGrade(input.grade);
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
throw new Error("班级类型不正确");
}
const classType = getClassType(input.classTypeId);
if (!classType) {
throw new Error("班级类型不存在");
}
if (classType.grade !== input.grade) {
throw new Error("班级类型不属于当前年级");
}
if (!input.english.trim()) {
throw new Error("题库英语不能为空");
}
}
function validateGrade(grade: Grade) {
if (!["小班", "中班", "大班"].includes(grade)) {
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;
}