refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
164
src/modules/card/card-action-dto.ts
Normal file
164
src/modules/card/card-action-dto.ts
Normal 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;
|
||||
};
|
||||
428
src/modules/card/card-action.ts
Normal file
428
src/modules/card/card-action.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
104
src/modules/card/card-repository-dto.ts
Normal file
104
src/modules/card/card-repository-dto.ts
Normal 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;
|
||||
};
|
||||
309
src/modules/card/card-repository.ts
Normal file
309
src/modules/card/card-repository.ts
Normal 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;
|
||||
}
|
||||
113
src/modules/card/card-service-dto.ts
Normal file
113
src/modules/card/card-service-dto.ts
Normal 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;
|
||||
384
src/modules/card/card-service.ts
Normal file
384
src/modules/card/card-service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user