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,164 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
export const schemaActionInputCreateCard = z.object({
noteId: z.bigint(),
deckId: z.number().int().positive(),
ord: z.number().int().min(0).optional(),
});
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
export const schemaActionInputAnswerCard = z.object({
cardId: z.bigint(),
ease: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
]),
});
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>;
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard);
export const schemaActionInputGetCardsForReview = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>;
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview);
export const schemaActionInputGetNewCards = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
export const schemaActionInputGetCardsByDeckId = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
queue: z.union([
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
]).optional(),
});
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
export const schemaActionInputGetCardStats = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>;
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
export const schemaActionInputDeleteCard = z.object({
cardId: z.bigint(),
});
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
export const schemaActionInputGetCardById = z.object({
cardId: z.bigint(),
});
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
export type ActionOutputCard = {
id: string;
noteId: string;
deckId: number;
ord: number;
mod: number;
usn: number;
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCardWithNote = ActionOutputCard & {
note: {
id: string;
flds: string;
sfld: string;
tags: string;
};
};
export type ActionOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ActionOutputScheduledCard = {
cardId: string;
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ActionOutputCreateCard = {
success: boolean;
message: string;
data?: {
cardId: string;
};
};
export type ActionOutputAnswerCard = {
success: boolean;
message: string;
data?: {
card: ActionOutputCard;
scheduled: ActionOutputScheduledCard;
};
};
export type ActionOutputGetCards = {
success: boolean;
message: string;
data?: ActionOutputCard[];
};
export type ActionOutputGetCardsWithNote = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote[];
};
export type ActionOutputGetCardStats = {
success: boolean;
message: string;
data?: ActionOutputCardStats;
};
export type ActionOutputDeleteCard = {
success: boolean;
message: string;
};
export type ActionOutputGetCardById = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote;
};

View File

@@ -0,0 +1,428 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateCard,
ActionInputAnswerCard,
ActionInputGetCardsForReview,
ActionInputGetNewCards,
ActionInputGetCardsByDeckId,
ActionInputGetCardStats,
ActionInputDeleteCard,
ActionInputGetCardById,
ActionOutputCreateCard,
ActionOutputAnswerCard,
ActionOutputGetCards,
ActionOutputGetCardsWithNote,
ActionOutputGetCardStats,
ActionOutputDeleteCard,
ActionOutputGetCardById,
ActionOutputCard,
ActionOutputCardWithNote,
ActionOutputScheduledCard,
validateActionInputCreateCard,
validateActionInputAnswerCard,
validateActionInputGetCardsForReview,
validateActionInputGetNewCards,
validateActionInputGetCardsByDeckId,
validateActionInputGetCardStats,
validateActionInputDeleteCard,
validateActionInputGetCardById,
} from "./card-action-dto";
import {
serviceCreateCard,
serviceAnswerCard,
serviceGetCardsForReview,
serviceGetNewCards,
serviceGetCardsByDeckId,
serviceGetCardsByDeckIdWithNotes,
serviceGetCardStats,
serviceDeleteCard,
serviceGetCardByIdWithNote,
} from "./card-service";
import { repoGetCardDeckOwnerId } from "./card-repository";
import { CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-action");
function mapCardToOutput(card: {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputCard {
return {
id: card.id.toString(),
noteId: card.noteId.toString(),
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type as ActionOutputCard["type"],
queue: card.queue as ActionOutputCard["queue"],
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
function mapCardWithNoteToOutput(card: {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
}): ActionOutputCardWithNote {
return {
...mapCardToOutput(card),
note: {
id: card.note.id.toString(),
flds: card.note.flds,
sfld: card.note.sfld,
tags: card.note.tags,
},
};
}
function mapScheduledToOutput(scheduled: {
cardId: bigint;
newType: string;
newQueue: string;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
}): ActionOutputScheduledCard {
return {
cardId: scheduled.cardId.toString(),
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
newDue: scheduled.newDue,
newIvl: scheduled.newIvl,
newFactor: scheduled.newFactor,
newReps: scheduled.newReps,
newLapses: scheduled.newLapses,
nextReviewDate: scheduled.nextReviewDate,
};
}
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const ownerId = await repoGetCardDeckOwnerId(cardId);
return ownerId === session.user.id;
}
async function getCurrentUserId(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user?.id ?? null;
}
export async function actionCreateCard(
input: unknown,
): Promise<ActionOutputCreateCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputCreateCard(input);
const cardId = await serviceCreateCard(validated);
return {
success: true,
message: "Card created successfully",
data: { cardId: cardId.toString() },
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to create card", { error: e });
return { success: false, message: "An error occurred while creating the card" };
}
}
export async function actionAnswerCard(
input: unknown,
): Promise<ActionOutputAnswerCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputAnswerCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) {
return { success: false, message: "You do not have permission to answer this card" };
}
const result = await serviceAnswerCard(validated);
return {
success: true,
message: "Card answered successfully",
data: {
card: mapCardToOutput(result.card),
scheduled: mapScheduledToOutput(result.scheduled),
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to answer card", { error: e });
return { success: false, message: "An error occurred while answering the card" };
}
}
export async function actionGetCardsForReview(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsForReview(input);
const cards = await serviceGetCardsForReview(validated);
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards for review", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetNewCards(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetNewCards(input);
const cards = await serviceGetNewCards(validated);
return {
success: true,
message: "New cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get new cards", { error: e });
return { success: false, message: "An error occurred while fetching new cards" };
}
}
export async function actionGetCardsByDeckId(
input: unknown,
): Promise<ActionOutputGetCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsByDeckId(input);
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
const cards = await serviceGetCardsByDeckId({
...validated,
queue,
});
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards by deck", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetCardsByDeckIdWithNotes(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsByDeckId(input);
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
const cards = await serviceGetCardsByDeckIdWithNotes({
...validated,
queue,
});
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards by deck with notes", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetCardStats(
input: unknown,
): Promise<ActionOutputGetCardStats> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardStats(input);
const stats = await serviceGetCardStats(validated);
return {
success: true,
message: "Card stats fetched successfully",
data: stats,
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get card stats", { error: e });
return { success: false, message: "An error occurred while fetching card stats" };
}
}
export async function actionDeleteCard(
input: unknown,
): Promise<ActionOutputDeleteCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputDeleteCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) {
return { success: false, message: "You do not have permission to delete this card" };
}
await serviceDeleteCard(validated.cardId);
return { success: true, message: "Card deleted successfully" };
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to delete card", { error: e });
return { success: false, message: "An error occurred while deleting the card" };
}
}
export async function actionGetCardById(
input: unknown,
): Promise<ActionOutputGetCardById> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardById(input);
const card = await serviceGetCardByIdWithNote(validated.cardId);
if (!card) {
return { success: false, message: "Card not found" };
}
return {
success: true,
message: "Card fetched successfully",
data: mapCardWithNoteToOutput(card),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get card by id", { error: e });
return { success: false, message: "An error occurred while fetching the card" };
}
}

View File

@@ -0,0 +1,104 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export interface RepoInputCreateCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
due: number;
type?: CardType;
queue?: CardQueue;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputUpdateCard {
ord?: number;
mod?: number;
usn?: number;
type?: CardType;
queue?: CardQueue;
due?: number;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface RepoInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface RepoInputGetNewCards {
deckId: number;
limit?: number;
}
export interface RepoInputBulkUpdateCard {
id: bigint;
data: RepoInputUpdateCard;
}
export interface RepoInputBulkUpdateCards {
cards: RepoInputBulkUpdateCard[];
}
export type RepoOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputCardWithNote = RepoOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type RepoOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};

View File

@@ -0,0 +1,309 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateCard,
RepoInputUpdateCard,
RepoInputGetCardsByDeckId,
RepoInputGetCardsForReview,
RepoInputGetNewCards,
RepoInputBulkUpdateCards,
RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats,
} from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-repository");
export async function repoCreateCard(
input: RepoInputCreateCard,
): Promise<bigint> {
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
const card = await prisma.card.create({
data: {
id: input.id,
noteId: input.noteId,
deckId: input.deckId,
ord: input.ord,
due: input.due,
mod: Math.floor(Date.now() / 1000),
type: input.type ?? CardType.NEW,
queue: input.queue ?? CardQueue.NEW,
ivl: input.ivl ?? 0,
factor: input.factor ?? 2500,
reps: input.reps ?? 0,
lapses: input.lapses ?? 0,
left: input.left ?? 0,
odue: input.odue ?? 0,
odid: input.odid ?? 0,
flags: input.flags ?? 0,
data: input.data ?? "",
},
});
log.info("Card created", { cardId: card.id.toString() });
return card.id;
}
export async function repoUpdateCard(
id: bigint,
input: RepoInputUpdateCard,
): Promise<void> {
log.debug("Updating card", { cardId: id.toString() });
await prisma.card.update({
where: { id },
data: {
...input,
updatedAt: new Date(),
},
});
log.info("Card updated", { cardId: id.toString() });
}
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> {
const card = await prisma.card.findUnique({
where: { id },
});
return card;
}
export async function repoGetCardByIdWithNote(
id: bigint,
): Promise<RepoOutputCardWithNote | null> {
const card = await prisma.card.findUnique({
where: { id },
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
});
return card;
}
export async function repoGetCardsByDeckId(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCard[]> {
const { deckId, limit = 50, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
orderBy: { due: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsByDeckIdWithNotes(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 100, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { id: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsForReview(
input: RepoInputGetCardsForReview,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const cards = await prisma.card.findMany({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
take: limit,
});
log.debug("Fetched cards for review", { deckId, count: cards.length });
return cards;
}
export async function repoGetNewCards(
input: RepoInputGetNewCards,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const cards = await prisma.card.findMany({
where: {
deckId,
type: CardType.NEW,
queue: CardQueue.NEW,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { due: "asc" },
take: limit,
});
log.debug("Fetched new cards", { deckId, count: cards.length });
return cards;
}
export async function repoDeleteCard(id: bigint): Promise<void> {
log.debug("Deleting card", { cardId: id.toString() });
await prisma.card.delete({
where: { id },
});
log.info("Card deleted", { cardId: id.toString() });
}
export async function repoBulkUpdateCards(
input: RepoInputBulkUpdateCards,
): Promise<void> {
log.debug("Bulk updating cards", { count: input.cards.length });
await prisma.$transaction(
input.cards.map((item) =>
prisma.card.update({
where: { id: item.id },
data: {
...item.data,
updatedAt: new Date(),
},
}),
),
);
log.info("Bulk update completed", { count: input.cards.length });
}
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const [total, newCards, learning, review, due] = await Promise.all([
prisma.card.count({ where: { deckId } }),
prisma.card.count({ where: { deckId, type: CardType.NEW } }),
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }),
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }),
prisma.card.count({
where: {
deckId,
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
}),
]);
return { total, new: newCards, learning, review, due };
}
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> {
const card = await prisma.card.findUnique({
where: { id: cardId },
include: {
deck: {
select: { userId: true },
},
},
});
return card?.deck.userId ?? null;
}
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const card = await prisma.card.findFirst({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
});
return card;
}
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> {
const cards = await prisma.card.findMany({
where: { noteId },
orderBy: { ord: "asc" },
});
return cards;
}

View File

@@ -0,0 +1,113 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export type ReviewEase = 1 | 2 | 3 | 4;
export interface ServiceInputCreateCard {
noteId: bigint;
deckId: number;
ord?: number;
}
export interface ServiceInputAnswerCard {
cardId: bigint;
ease: ReviewEase;
}
export interface ServiceInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface ServiceInputGetNewCards {
deckId: number;
limit?: number;
}
export interface ServiceInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface ServiceInputGetCardStats {
deckId: number;
}
export type ServiceOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCardWithNote = ServiceOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type ServiceOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ServiceOutputScheduledCard = {
cardId: bigint;
newType: CardType;
newQueue: CardQueue;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ServiceOutputReviewResult = {
success: boolean;
card: ServiceOutputCard;
scheduled: ServiceOutputScheduledCard;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
GRADUATING_INTERVAL_GOOD: 1,
GRADUATING_INTERVAL_EASY: 4,
EASY_INTERVAL: 4,
MINIMUM_FACTOR: 1300,
DEFAULT_FACTOR: 2500,
FACTOR_ADJUSTMENTS: {
1: -200,
2: -150,
3: 0,
4: 150,
},
INITIAL_INTERVALS: {
2: 1,
3: 3,
4: 4,
},
} as const;

View File

@@ -0,0 +1,384 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateCard,
repoUpdateCard,
repoGetCardById,
repoGetCardByIdWithNote,
repoGetCardsByDeckId,
repoGetCardsByDeckIdWithNotes,
repoGetCardsForReview,
repoGetNewCards,
repoGetCardStats,
repoDeleteCard,
repoGetCardsByNoteId,
} from "./card-repository";
import {
RepoInputUpdateCard,
RepoOutputCard,
} from "./card-repository-dto";
import {
ServiceInputCreateCard,
ServiceInputAnswerCard,
ServiceInputGetCardsForReview,
ServiceInputGetNewCards,
ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
ServiceOutputScheduledCard,
ServiceOutputReviewResult,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-service");
function generateCardId(): bigint {
return BigInt(Date.now());
}
function calculateDueDate(intervalDays: number): number {
const now = Math.floor(Date.now() / 1000);
const todayStart = Math.floor(now / 86400) * 86400;
return Math.floor(todayStart / 86400) + intervalDays;
}
function calculateNextReviewTime(intervalDays: number): Date {
const now = Date.now();
return new Date(now + intervalDays * 86400 * 1000);
}
function scheduleNewCard(ease: ReviewEase, factor: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
};
}
const ivl = SM2_CONFIG.INITIAL_INTERVALS[ease];
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
};
}
function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLeft: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length,
};
}
const stepIndex = Math.floor(left % 1000);
if (ease === 2 && stepIndex < SM2_CONFIG.LEARNING_STEPS.length - 1) {
const nextStep = stepIndex + 1;
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]),
newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep),
};
}
const ivl = ease === 4 ? SM2_CONFIG.GRADUATING_INTERVAL_EASY : SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
newLeft: 0,
};
}
function scheduleReviewCard(
ease: ReviewEase,
ivl: number,
factor: number,
lapses: number,
): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLapses: number;
} {
if (ease === 1) {
return {
type: CardType.RELEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLapses: lapses + 1,
};
}
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]);
const factorMultiplier = newFactor / 1000;
let newIvl = Math.floor(ivl * factorMultiplier);
if (ease === 2) {
newIvl = Math.max(1, Math.floor(newIvl * 1.2));
} else if (ease === 4) {
newIvl = Math.floor(newIvl * 1.3);
}
newIvl = Math.max(1, newIvl);
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl: newIvl,
due: calculateDueDate(newIvl),
newFactor,
newLapses: lapses,
};
}
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
return {
id: card.id,
noteId: card.noteId,
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type,
queue: card.queue,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
export async function serviceCreateCard(
input: ServiceInputCreateCard,
): Promise<bigint> {
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
const existingCards = await repoGetCardsByNoteId(input.noteId);
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
const ord = input.ord ?? maxOrd + 1;
const cardId = await repoCreateCard({
id: generateCardId(),
noteId: input.noteId,
deckId: input.deckId,
ord,
due: ord,
type: CardType.NEW,
queue: CardQueue.NEW,
});
log.info("Card created", { cardId: cardId.toString() });
return cardId;
}
export async function serviceAnswerCard(
input: ServiceInputAnswerCard,
): Promise<ServiceOutputReviewResult> {
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
const card = await repoGetCardById(input.cardId);
if (!card) {
throw new Error(`Card not found: ${input.cardId.toString()}`);
}
const { ease } = input;
let updateData: RepoInputUpdateCard;
let scheduled: ServiceOutputScheduledCard;
if (card.type === CardType.NEW) {
const result = scheduleNewCard(ease, card.factor);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.type === CardType.LEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
const result = scheduleLearningCard(ease, card.factor, card.left);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.newLeft,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else {
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
lapses: result.newLapses,
left: result.type === CardType.RELEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: result.newLapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
}
await repoUpdateCard(input.cardId, updateData);
const updatedCard = await repoGetCardById(input.cardId);
if (!updatedCard) {
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
}
log.info("Card answered and scheduled", {
cardId: input.cardId.toString(),
newType: scheduled.newType,
newIvl: scheduled.newIvl,
nextReview: scheduled.nextReviewDate.toISOString(),
});
return {
success: true,
card: mapToServiceOutput(updatedCard),
scheduled,
};
}
export async function serviceGetNextCardForReview(
deckId: number,
): Promise<ServiceOutputCardWithNote | null> {
log.debug("Getting next card for review", { deckId });
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
return cards[0] ?? null;
}
export async function serviceGetCardsForReview(
input: ServiceInputGetCardsForReview,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards for review", { deckId: input.deckId });
return repoGetCardsForReview(input);
}
export async function serviceGetNewCards(
input: ServiceInputGetNewCards,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting new cards", { deckId: input.deckId });
return repoGetNewCards(input);
}
export async function serviceGetCardsByDeckId(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCard[]> {
log.debug("Getting cards by deck", { deckId: input.deckId });
const cards = await repoGetCardsByDeckId(input);
return cards.map(mapToServiceOutput);
}
export async function serviceGetCardsByDeckIdWithNotes(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
return repoGetCardsByDeckIdWithNotes(input);
}
export async function serviceGetCardById(
cardId: bigint,
): Promise<ServiceOutputCard | null> {
const card = await repoGetCardById(cardId);
return card ? mapToServiceOutput(card) : null;
}
export async function serviceGetCardByIdWithNote(
cardId: bigint,
): Promise<ServiceOutputCardWithNote | null> {
return repoGetCardByIdWithNote(cardId);
}
export async function serviceGetCardStats(
input: ServiceInputGetCardStats,
): Promise<ServiceOutputCardStats> {
log.debug("Getting card stats", { deckId: input.deckId });
return repoGetCardStats(input.deckId);
}
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
log.info("Deleting card", { cardId: cardId.toString() });
await repoDeleteCard(cardId);
}