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

@@ -0,0 +1,207 @@
/*
Warnings:
- You are about to drop the `folder_favorites` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `folders` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `pairs` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "CardType" AS ENUM ('NEW', 'LEARNING', 'REVIEW', 'RELEARNING');
-- CreateEnum
CREATE TYPE "CardQueue" AS ENUM ('USER_BURIED', 'SCHED_BURIED', 'SUSPENDED', 'NEW', 'LEARNING', 'REVIEW', 'IN_LEARNING', 'PREVIEW');
-- CreateEnum
CREATE TYPE "NoteKind" AS ENUM ('STANDARD', 'CLOZE');
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_folder_id_fkey";
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_user_id_fkey";
-- DropForeignKey
ALTER TABLE "folders" DROP CONSTRAINT "folders_user_id_fkey";
-- DropForeignKey
ALTER TABLE "pairs" DROP CONSTRAINT "pairs_folder_id_fkey";
-- DropTable
DROP TABLE "folder_favorites";
-- DropTable
DROP TABLE "folders";
-- DropTable
DROP TABLE "pairs";
-- CreateTable
CREATE TABLE "note_types" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"kind" "NoteKind" NOT NULL DEFAULT 'STANDARD',
"css" TEXT NOT NULL DEFAULT '',
"fields" JSONB NOT NULL DEFAULT '[]',
"templates" JSONB NOT NULL DEFAULT '[]',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "note_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "decks" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"collapsed" BOOLEAN NOT NULL DEFAULT false,
"conf" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "decks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deck_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"deck_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "deck_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notes" (
"id" BIGINT NOT NULL,
"guid" TEXT NOT NULL,
"note_type_id" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"tags" TEXT NOT NULL DEFAULT ' ',
"flds" TEXT NOT NULL,
"sfld" TEXT NOT NULL,
"csum" INTEGER NOT NULL,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cards" (
"id" BIGINT NOT NULL,
"note_id" BIGINT NOT NULL,
"deck_id" INTEGER NOT NULL,
"ord" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"type" "CardType" NOT NULL DEFAULT 'NEW',
"queue" "CardQueue" NOT NULL DEFAULT 'NEW',
"due" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL DEFAULT 0,
"factor" INTEGER NOT NULL DEFAULT 2500,
"reps" INTEGER NOT NULL DEFAULT 0,
"lapses" INTEGER NOT NULL DEFAULT 0,
"left" INTEGER NOT NULL DEFAULT 0,
"odue" INTEGER NOT NULL DEFAULT 0,
"odid" INTEGER NOT NULL DEFAULT 0,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "revlogs" (
"id" BIGINT NOT NULL,
"card_id" BIGINT NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"ease" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL,
"lastIvl" INTEGER NOT NULL,
"factor" INTEGER NOT NULL,
"time" INTEGER NOT NULL,
"type" INTEGER NOT NULL,
CONSTRAINT "revlogs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "note_types_user_id_idx" ON "note_types"("user_id");
-- CreateIndex
CREATE INDEX "decks_user_id_idx" ON "decks"("user_id");
-- CreateIndex
CREATE INDEX "decks_visibility_idx" ON "decks"("visibility");
-- CreateIndex
CREATE INDEX "deck_favorites_user_id_idx" ON "deck_favorites"("user_id");
-- CreateIndex
CREATE INDEX "deck_favorites_deck_id_idx" ON "deck_favorites"("deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "deck_favorites_user_id_deck_id_key" ON "deck_favorites"("user_id", "deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "notes_guid_key" ON "notes"("guid");
-- CreateIndex
CREATE INDEX "notes_user_id_idx" ON "notes"("user_id");
-- CreateIndex
CREATE INDEX "notes_note_type_id_idx" ON "notes"("note_type_id");
-- CreateIndex
CREATE INDEX "notes_csum_idx" ON "notes"("csum");
-- CreateIndex
CREATE INDEX "cards_note_id_idx" ON "cards"("note_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_idx" ON "cards"("deck_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_queue_due_idx" ON "cards"("deck_id", "queue", "due");
-- CreateIndex
CREATE INDEX "revlogs_card_id_idx" ON "revlogs"("card_id");
-- AddForeignKey
ALTER TABLE "note_types" ADD CONSTRAINT "note_types_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_note_type_id_fkey" FOREIGN KEY ("note_type_id") REFERENCES "note_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "revlogs" ADD CONSTRAINT "revlogs_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -7,6 +7,10 @@ datasource db {
provider = "postgresql"
}
// ============================================
// User & Auth
// ============================================
model User {
id String @id
name String
@@ -20,8 +24,11 @@ model User {
bio String?
accounts Account[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
// Anki-compatible relations
decks Deck[]
deckFavorites DeckFavorite[]
noteTypes NoteType[]
notes Note[]
sessions Session[]
translationHistories TranslationHistory[]
followers Follow[] @relation("UserFollowers")
@@ -77,60 +84,175 @@ model Verification {
@@map("verification")
}
model Pair {
id Int @id @default(autoincrement())
language1 String
language2 String
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
// ============================================
// Anki-compatible Models
// ============================================
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
enum CardType {
NEW
LEARNING
REVIEW
RELEARNING
}
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
enum CardQueue {
USER_BURIED
SCHED_BURIED
SUSPENDED
NEW
LEARNING
REVIEW
IN_LEARNING
PREVIEW
}
/// Note type: 0=standard, 1=cloze
enum NoteKind {
STANDARD
CLOZE
}
/// Deck visibility (our extension, not in Anki)
enum Visibility {
PRIVATE
PUBLIC
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
/// NoteType (Anki: models) - Defines fields and templates for notes
model NoteType {
id Int @id @default(autoincrement())
name String
kind NoteKind @default(STANDARD)
css String @default("")
fields Json @default("[]")
templates Json @default("[]")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[]
@@index([userId])
@@map("note_types")
}
/// Deck (Anki: decks) - Container for cards
model Deck {
id Int @id @default(autoincrement())
name String
desc String @default("")
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
collapsed Boolean @default(false)
conf Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cards Card[]
favorites DeckFavorite[]
@@index([userId])
@@index([visibility])
@@map("folders")
@@map("decks")
}
model FolderFavorite {
/// DeckFavorite - Users can favorite public decks
model DeckFavorite {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
deckId Int @map("deck_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@unique([userId, deckId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
@@index([deckId])
@@map("deck_favorites")
}
/// Note (Anki: notes) - Contains field data, one note can have multiple cards
model Note {
id BigInt @id
guid String @unique
noteTypeId Int @map("note_type_id")
mod Int
usn Int @default(-1)
tags String @default(" ")
flds String
sfld String
csum Int
flags Int @default(0)
data String @default("")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
noteType NoteType @relation(fields: [noteTypeId], references: [id], onDelete: Cascade)
cards Card[]
@@index([userId])
@@index([noteTypeId])
@@index([csum])
@@map("notes")
}
/// Card (Anki: cards) - Scheduling information, what you review
model Card {
id BigInt @id
noteId BigInt @map("note_id")
deckId Int @map("deck_id")
ord Int
mod Int
usn Int @default(-1)
type CardType @default(NEW)
queue CardQueue @default(NEW)
due Int
ivl Int @default(0)
factor Int @default(2500)
reps Int @default(0)
lapses Int @default(0)
left Int @default(0)
odue Int @default(0)
odid Int @default(0)
flags Int @default(0)
data String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
revlogs Revlog[]
@@index([noteId])
@@index([deckId])
@@index([deckId, queue, due])
@@map("cards")
}
/// Revlog (Anki: revlog) - Review history
model Revlog {
id BigInt @id
cardId BigInt @map("card_id")
usn Int @default(-1)
ease Int
ivl Int
lastIvl Int
factor Int
time Int
type Int
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([cardId])
@@map("revlogs")
}
// ============================================
// Other Models
// ============================================
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
@@ -140,8 +262,8 @@ model DictionaryLookUp {
createdAt DateTime @default(now()) @map("created_at")
dictionaryItemId Int? @map("dictionary_item_id")
normalizedText String @default("") @map("normalized_text")
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
user User? @relation(fields: [userId], references: [id])
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])