refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View File

@@ -1,18 +1,69 @@
"use server";
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository";
import { repoCreateCard } from "@/modules/card/card-repository";
import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("ocr-service");
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const existingTypes = await repoGetNoteTypesByUserId({ userId });
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
if (existing) {
return existing.id;
}
const fields = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
];
const templates = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
},
];
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
const noteTypeId = await repoCreateNoteType({
name: VOCABULARY_NOTE_TYPE_NAME,
kind: NoteKind.STANDARD,
css,
fields,
templates,
userId,
});
log.info("Created vocabulary note type", { noteTypeId, userId });
return noteTypeId;
}
export async function serviceProcessOCR(
input: ServiceInputProcessOCR
): Promise<ServiceOutputProcessOCR> {
log.info("Processing OCR request", { folderId: input.folderId });
log.info("Processing OCR request", { deckId: input.deckId });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
@@ -20,15 +71,15 @@ export async function serviceProcessOCR(
return { success: false, message: "Unauthorized" };
}
const folderOwner = await repoGetUserIdByFolderId(input.folderId);
if (folderOwner !== session.user.id) {
log.warn("Folder ownership mismatch", {
folderId: input.folderId,
const deckOwner = await repoGetUserIdByDeckId(input.deckId);
if (deckOwner !== session.user.id) {
log.warn("Deck ownership mismatch", {
deckId: input.deckId,
userId: session.user.id
});
return {
success: false,
message: "You don't have permission to modify this folder"
message: "You don't have permission to modify this deck"
};
}
@@ -59,19 +110,38 @@ export async function serviceProcessOCR(
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id);
let pairsCreated = 0;
for (const pair of ocrResult.pairs) {
try {
await repoCreatePair({
folderId: input.folderId,
language1: sourceLanguage,
language2: targetLanguage,
text1: pair.word,
text2: pair.definition,
const now = Date.now();
const noteId = await repoCreateNote({
noteTypeId,
userId: session.user.id,
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
tags: ["ocr"],
});
await repoCreateCard({
id: BigInt(now + pairsCreated),
noteId,
deckId: input.deckId,
ord: 0,
due: pairsCreated + 1,
});
await repoCreateCard({
id: BigInt(now + pairsCreated + 10000),
noteId,
deckId: input.deckId,
ord: 1,
due: pairsCreated + 1,
});
pairsCreated++;
} catch (error) {
log.error("Failed to create pair", {
log.error("Failed to create note/card", {
word: pair.word,
error
});