Compare commits

...

5 Commits

Author SHA1 Message Date
cbb9326f84 refactor(anki): improve APKG import/export reliability
- Use crypto.getRandomValues for GUID generation
- Use SHA1 checksum for consistent hashing
- Add proper deep cloning for note type fields/templates
- Improve unique ID generation with timestamp XOR random
- Add file size validation for APKG uploads

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:10:34 +08:00
49ad953add fix(card): improve SM-2 algorithm compatibility with Anki
- Fix scheduleNewCard ease===3 (Good) to use steps[1] or graduate
- Fix scheduleLearningCard ease===2 (Hard) to repeat current step
- Ensure graduating cards get DEFAULT_FACTOR
- Fix interval calculations for learning card graduation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:09:54 +08:00
f1eafa8015 i18n: add card type labels for memorize feature
Add translations for cardTypeNew, cardTypeLearning, cardTypeReview,
cardTypeRelearning in all 8 supported languages (en-US, zh-CN, ja-JP,
ko-KR, de-DE, fr-FR, it-IT, ug-CN).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:08:02 +08:00
12e502313b feat(memorize): enhance review UI with dynamic intervals and keyboard shortcuts
- Add keyboard shortcuts: Space/Enter to show answer, 1-4 for responses
- Display dynamic preview intervals on answer buttons (1m, 6m, 4d, etc.)
- Add card type indicator (New/Learning/Review/Relearning) with color badges
- Highlight Good button as recommended option with ring and icon
- Show keyboard hint on Show Answer button

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:07:49 +08:00
13e8f51ada feat(memorize): add interval preview calculation utility
- Add calculatePreviewIntervals for Again/Hard/Good/Easy buttons
- Support NEW, LEARNING, RELEARNING, and REVIEW card types
- Use SM2_CONFIG constants for accurate interval calculation
- All intervals returned in minutes for consistent formatting

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:07:32 +08:00
14 changed files with 672 additions and 445 deletions

View File

@@ -222,7 +222,11 @@
"days": "{count} Tage",
"months": "{count} Monate",
"minAbbr": "m",
"dayAbbr": "T"
"dayAbbr": "T",
"cardTypeNew": "Neu",
"cardTypeLearning": "Lernen",
"cardTypeReview": "Wiederholung",
"cardTypeRelearning": "Neu lernen"
},
"page": {
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"

View File

@@ -213,7 +213,11 @@
"days": "{count}d",
"months": "{count}mo",
"minAbbr": "m",
"dayAbbr": "d"
"dayAbbr": "d",
"cardTypeNew": "New",
"cardTypeLearning": "Learning",
"cardTypeReview": "Review",
"cardTypeRelearning": "Relearning"
},
"page": {
"unauthorized": "You are not authorized to access this deck"

View File

@@ -222,7 +222,11 @@
"days": "{count}j",
"months": "{count}mois",
"minAbbr": "m",
"dayAbbr": "j"
"dayAbbr": "j",
"cardTypeNew": "Nouveau",
"cardTypeLearning": "Apprentissage",
"cardTypeReview": "Révision",
"cardTypeRelearning": "Réapprentissage"
},
"page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck"

View File

@@ -222,7 +222,11 @@
"days": "{count}g",
"months": "{count}mesi",
"minAbbr": "m",
"dayAbbr": "g"
"dayAbbr": "g",
"cardTypeNew": "Nuovo",
"cardTypeLearning": "Apprendimento",
"cardTypeReview": "Ripasso",
"cardTypeRelearning": "Riapprendimento"
},
"page": {
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"

View File

@@ -213,7 +213,11 @@
"days": "{count}日",
"months": "{count}ヶ月",
"minAbbr": "分",
"dayAbbr": "日"
"dayAbbr": "日",
"cardTypeNew": "新規",
"cardTypeLearning": "学習中",
"cardTypeReview": "復習",
"cardTypeRelearning": "再学習"
},
"page": {
"unauthorized": "このデッキにアクセスする権限がありません"

View File

@@ -222,7 +222,11 @@
"days": "{count}일",
"months": "{count}개월",
"minAbbr": "분",
"dayAbbr": "일"
"dayAbbr": "일",
"cardTypeNew": "새 카드",
"cardTypeLearning": "학습 중",
"cardTypeReview": "복습 중",
"cardTypeRelearning": "재학습 중"
},
"page": {
"unauthorized": "이 덱에 접근할 권한이 없습니다"

View File

@@ -222,7 +222,11 @@
"days": "{count} كۈن",
"months": "{count} ئاي",
"minAbbr": "م",
"dayAbbr": "ك"
"dayAbbr": "ك",
"cardTypeNew": "يېڭى",
"cardTypeLearning": "ئۆگىنىۋاتىدۇ",
"cardTypeReview": "تەكرارلاش",
"cardTypeRelearning": "قايتا ئۆگىنىش"
},
"page": {
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"

View File

@@ -213,7 +213,11 @@
"days": "{count} 天",
"months": "{count} 个月",
"minAbbr": "分",
"dayAbbr": "天"
"dayAbbr": "天",
"cardTypeNew": "新卡片",
"cardTypeLearning": "学习中",
"cardTypeReview": "复习中",
"cardTypeRelearning": "重学中"
},
"page": {
"unauthorized": "您无权访问该牌组"

View File

@@ -1,14 +1,16 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { useState, useEffect, useTransition, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { Layers, Check, Clock } from "lucide-react";
import { Layers, Check, Clock, Sparkles } from "lucide-react";
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { CardType } from "../../../../generated/prisma/enums";
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
@@ -70,11 +72,11 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
return card.note.flds.split('\x1f');
};
const handleShowAnswer = () => {
const handleShowAnswer = useCallback(() => {
setShowAnswer(true);
};
}, []);
const handleAnswer = (ease: ReviewEase) => {
const handleAnswer = useCallback((ease: ReviewEase) => {
const card = getCurrentCard();
if (!card) return;
@@ -101,8 +103,40 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
setError(result.message);
}
});
}, [cards, currentIndex]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (!showAnswer) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleShowAnswer();
}
} else {
if (e.key === "1") {
e.preventDefault();
handleAnswer(1);
} else if (e.key === "2") {
e.preventDefault();
handleAnswer(2);
} else if (e.key === "3" || e.key === " " || e.key === "Enter") {
e.preventDefault();
handleAnswer(3);
} else if (e.key === "4") {
e.preventDefault();
handleAnswer(4);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showAnswer, handleShowAnswer, handleAnswer]);
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
const now = new Date();
const nextReview = new Date(scheduled.nextReviewDate);
@@ -127,6 +161,36 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
return t("months", { count: Math.floor(ivl / 30) });
};
const getCardTypeLabel = (type: CardType): string => {
switch (type) {
case CardType.NEW:
return t("cardTypeNew");
case CardType.LEARNING:
return t("cardTypeLearning");
case CardType.REVIEW:
return t("cardTypeReview");
case CardType.RELEARNING:
return t("cardTypeRelearning");
default:
return "";
}
};
const getCardTypeColor = (type: CardType): string => {
switch (type) {
case CardType.NEW:
return "bg-blue-100 text-blue-700";
case CardType.LEARNING:
return "bg-yellow-100 text-yellow-700";
case CardType.REVIEW:
return "bg-green-100 text-green-700";
case CardType.RELEARNING:
return "bg-purple-100 text-purple-700";
default:
return "bg-gray-100 text-gray-700";
}
};
if (isLoading) {
return (
<PageLayout>
@@ -173,6 +237,14 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const front = fields[0] ?? "";
const back = fields[1] ?? "";
const cardPreview: CardPreview = {
type: currentCard.type,
ivl: currentCard.ivl,
factor: currentCard.factor,
left: currentCard.left,
};
const previewIntervals = calculatePreviewIntervals(cardPreview);
return (
<PageLayout>
<div className="flex items-center justify-between mb-4">
@@ -180,8 +252,13 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
<Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span>
</div>
<div className="text-sm text-gray-500">
<div className="flex items-center gap-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${getCardTypeColor(currentCard.type)}`}>
{getCardTypeLabel(currentCard.type)}
</span>
<span className="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
</span>
</div>
</div>
@@ -238,43 +315,51 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
className="px-8 py-3 text-lg rounded-full"
>
{t("showAnswer")}
<span className="ml-2 text-xs opacity-60">Space</span>
</LightButton>
) : (
<div className="flex flex-wrap justify-center gap-3">
<button
onClick={() => handleAnswer(1)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
className="flex flex-col items-center px-5 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("again")}</span>
<span className="text-xs opacity-75">&lt;1{t("minAbbr")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.again)}</span>
<span className="text-xs opacity-50 mt-1">1</span>
</button>
<button
onClick={() => handleAnswer(2)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
className="flex flex-col items-center px-5 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("hard")}</span>
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.hard)}</span>
<span className="text-xs opacity-50 mt-1">2</span>
</button>
<button
onClick={() => handleAnswer(3)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
className="flex flex-col items-center px-5 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50 min-w-[80px] ring-2 ring-green-300"
>
<div className="flex items-center gap-1">
<span className="font-medium">{t("good")}</span>
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
<Sparkles className="w-3 h-3 opacity-60" />
</div>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.good)}</span>
<span className="text-xs opacity-50 mt-1">3/Space</span>
</button>
<button
onClick={() => handleAnswer(4)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
className="flex flex-col items-center px-5 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("easy")}</span>
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.easy)}</span>
<span className="text-xs opacity-50 mt-1">4</span>
</button>
</div>
)}

View File

@@ -0,0 +1,84 @@
import { CardType } from "../../../../generated/prisma/enums";
import { SM2_CONFIG } from "@/modules/card/card-service-dto";
export interface CardPreview {
type: CardType;
ivl: number;
factor: number;
left: number;
}
export interface PreviewIntervals {
again: number;
hard: number;
good: number;
easy: number;
}
function calculateReviewIntervals(ivl: number, factor: number): PreviewIntervals {
const MINUTES_PER_DAY = 1440;
return {
again: Math.max(1, Math.floor(ivl * SM2_CONFIG.NEW_INTERVAL)) * MINUTES_PER_DAY,
hard: Math.floor(ivl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
good: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
easy: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
};
}
function calculateNewCardIntervals(): PreviewIntervals {
const steps = SM2_CONFIG.LEARNING_STEPS;
return {
again: steps[0],
hard: steps.length >= 2 ? (steps[0] + steps[1]) / 2 : steps[0],
good: steps.length >= 2 ? steps[1] : SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440,
easy: SM2_CONFIG.EASY_INTERVAL * 1440,
};
}
function calculateLearningIntervals(left: number): PreviewIntervals {
const steps = SM2_CONFIG.LEARNING_STEPS;
const stepIndex = Math.floor(left % 1000);
const again = steps[0];
let hard: number;
if (stepIndex === 0 && steps.length >= 2) {
hard = (steps[0] + steps[1]) / 2;
} else if (stepIndex < steps.length - 1) {
hard = steps[stepIndex];
} else {
hard = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
}
let good: number;
if (stepIndex < steps.length - 1) {
good = steps[stepIndex + 1];
} else {
good = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
}
const easy = SM2_CONFIG.GRADUATING_INTERVAL_EASY * 1440;
return { again, hard, good, easy };
}
export function calculatePreviewIntervals(card: CardPreview): PreviewIntervals {
switch (card.type) {
case CardType.NEW:
return calculateNewCardIntervals();
case CardType.LEARNING:
case CardType.RELEARNING:
return calculateLearningIntervals(card.left);
case CardType.REVIEW:
default:
return calculateReviewIntervals(card.ivl, card.factor);
}
}
export function formatPreviewInterval(minutes: number): string {
if (minutes < 1) return "<1";
if (minutes < 60) return `${Math.round(minutes)}`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes / 1440)}d`;
}

View File

@@ -1,6 +1,7 @@
import JSZip from "jszip";
import initSqlJs from "sql.js";
import type { Database } from "sql.js";
import { createHash } from "crypto";
import type {
AnkiDeck,
AnkiNoteType,
@@ -10,30 +11,21 @@ import type {
AnkiRevlogRow,
} from "./types";
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
function generateGuid(): string {
let result = "";
const id = Date.now() ^ (Math.random() * 0xffffffff);
let num = BigInt(id);
let guid = "";
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) {
result = BASE91_CHARS[Number(num % 91n)] + result;
num = num / 91n;
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
}
return result;
return guid;
}
function checksum(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 100000000;
function checksum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
}
function createCollectionSql(): string {
@@ -198,6 +190,7 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const db = new SQL.Database();
try {
db.run(createCollectionSql());
const now = Date.now();
@@ -273,13 +266,15 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
ord: t.ord,
qfmt: t.qfmt,
afmt: t.afmt,
bqfmt: "",
bafmt: "",
did: null,
})),
css: data.noteType.css,
latexPre: "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
latexPost: "\\end{document}",
latexsvg: false,
req: [],
req: [[0, "any", [0]]],
},
};
@@ -393,10 +388,10 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
);
}
const dbData = db.export();
return db.export();
} finally {
db.close();
return dbData;
}
}
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {

View File

@@ -20,7 +20,7 @@ async function openDatabase(zip: JSZip): Promise<Database | null> {
const anki21 = zip.file("collection.anki21");
const anki2 = zip.file("collection.anki2");
let dbFile = anki21b || anki21 || anki2;
const dbFile = anki21b || anki21 || anki2;
if (!dbFile) return null;
const dbData = await dbFile.async("uint8array");
@@ -36,17 +36,17 @@ function parseJsonField<T>(jsonStr: string): T {
}
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
const results: T[] = [];
const stmt = db.prepare(sql);
try {
stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as T);
results.push(stmt.getAsObject() as T);
}
stmt.free();
return results;
} finally {
stmt.free();
}
}
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
@@ -62,6 +62,7 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
throw new Error("No valid Anki database found in APKG file");
}
try {
const col = queryOne<{
crt: number;
mod: number;
@@ -74,7 +75,6 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
if (!col) {
db.close();
throw new Error("Invalid APKG: no collection row found");
}
@@ -124,8 +124,6 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
}
}
db.close();
return {
decks: decksMap,
noteTypes: noteTypesMap,
@@ -140,6 +138,9 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
ver: col.ver,
},
};
} finally {
db.close();
}
}
export function getDeckNotesAndCards(

View File

@@ -95,14 +95,24 @@ function scheduleNewCard(ease: ReviewEase, currentFactor: number): {
}
if (ease === 3) {
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[1] * 60,
newFactor: currentFactor,
};
}
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
};
}
const ivl = SM2_CONFIG.EASY_INTERVAL;
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
@@ -153,16 +163,24 @@ function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: num
};
}
if (stepIndex < steps.length - 1) {
const nextStep = stepIndex + 1;
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60,
due: Math.floor(Date.now() / 1000) + steps[stepIndex] * 60,
newFactor: currentFactor,
newLeft: nextStep * 1000 + (totalSteps - nextStep),
newLeft: left,
};
}
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
newLeft: 0,
};
}
if (ease === 3) {

View File

@@ -2,21 +2,24 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { validate } from "@/utils/validate";
import { z } from "zod";
import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser";
import { prisma } from "@/lib/db";
import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums";
import { createLogger } from "@/lib/logger";
import { repoGenerateGuid, repoCalculateCsum } from "@/modules/note/note-repository";
import type { ParsedApkg } from "@/lib/anki/types";
import { randomBytes } from "crypto";
const log = createLogger("import-action");
const schemaImportApkg = z.object({
deckName: z.string().min(1).optional(),
});
const MAX_APKG_SIZE = 100 * 1024 * 1024;
export type ActionInputImportApkg = z.infer<typeof schemaImportApkg>;
export interface ActionOutputPreviewApkg {
success: boolean;
message: string;
decks?: { id: number; name: string; cardCount: number }[];
}
export interface ActionOutputImportApkg {
success: boolean;
@@ -26,12 +29,6 @@ export interface ActionOutputImportApkg {
cardCount?: number;
}
export interface ActionOutputPreviewApkg {
success: boolean;
message: string;
decks?: { id: number; name: string; cardCount: number }[];
}
async function importNoteType(
parsed: ParsedApkg,
ankiNoteTypeId: number,
@@ -75,8 +72,8 @@ async function importNoteType(
name: ankiNoteType.name,
kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD,
css: ankiNoteType.css,
fields: fields as unknown as object,
templates: templates as unknown as object,
fields: JSON.parse(JSON.stringify(fields)),
templates: JSON.parse(JSON.stringify(templates)),
userId,
},
});
@@ -108,41 +105,42 @@ function mapAnkiCardQueue(queue: number): CardQueue {
}
}
function generateUniqueId(): bigint {
const bytes = randomBytes(8);
const timestamp = BigInt(Date.now());
const random = BigInt(`0x${bytes.toString("hex")}`);
return timestamp ^ random;
}
async function importDeck(
parsed: ParsedApkg,
deckId: number,
ankiDeckId: number,
userId: string,
deckNameOverride?: string
deckName?: string
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
const ankiDeck = parsed.decks.get(deckId);
const ankiDeck = parsed.decks.get(ankiDeckId);
if (!ankiDeck) {
throw new Error(`Deck ${deckId} not found in APKG`);
throw new Error(`Deck ${ankiDeckId} not found in APKG`);
}
const deck = await prisma.deck.create({
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, ankiDeckId);
const result = await prisma.$transaction(async (tx) => {
const deck = await tx.deck.create({
data: {
name: deckNameOverride || ankiDeck.name,
desc: ankiDeck.desc || "",
visibility: "PRIVATE",
collapsed: ankiDeck.collapsed,
conf: JSON.parse(JSON.stringify(ankiDeck)),
name: deckName || ankiDeck.name,
desc: ankiDeck.desc,
userId,
collapsed: ankiDeck.collapsed,
conf: {},
},
});
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, deckId);
if (ankiNotes.length === 0) {
return { deckId: deck.id, noteCount: 0, cardCount: 0 };
}
const noteTypeIdMap = new Map<number, number>();
const firstNote = ankiNotes[0];
if (firstNote) {
const importedNoteTypeId = await importNoteType(parsed, firstNote.mid, userId);
noteTypeIdMap.set(firstNote.mid, importedNoteTypeId);
}
const noteIdMap = new Map<number, bigint>();
for (const ankiNote of ankiNotes) {
@@ -152,20 +150,23 @@ async function importDeck(
noteTypeIdMap.set(ankiNote.mid, noteTypeId);
}
const noteId = BigInt(Date.now() + Math.floor(Math.random() * 1000));
const noteId = generateUniqueId();
noteIdMap.set(ankiNote.id, noteId);
await prisma.note.create({
const guid = ankiNote.guid || repoGenerateGuid();
const csum = ankiNote.csum || repoCalculateCsum(ankiNote.sfld);
await tx.note.create({
data: {
id: noteId,
guid: ankiNote.guid,
guid,
noteTypeId,
mod: ankiNote.mod,
usn: ankiNote.usn,
tags: ankiNote.tags,
flds: ankiNote.flds,
sfld: ankiNote.sfld,
csum: ankiNote.csum,
csum,
flags: ankiNote.flags,
data: ankiNote.data,
userId,
@@ -180,9 +181,9 @@ async function importDeck(
continue;
}
await prisma.card.create({
await tx.card.create({
data: {
id: BigInt(ankiCard.id),
id: generateUniqueId(),
noteId,
deckId: deck.id,
ord: ankiCard.ord,
@@ -205,6 +206,9 @@ async function importDeck(
}
return { deckId: deck.id, noteCount: ankiNotes.length, cardCount: ankiCards.length };
});
return result;
}
export async function actionPreviewApkg(formData: FormData): Promise<ActionOutputPreviewApkg> {
@@ -222,6 +226,10 @@ export async function actionPreviewApkg(formData: FormData): Promise<ActionOutpu
return { success: false, message: "Invalid file type. Please upload an .apkg file" };
}
if (file.size > MAX_APKG_SIZE) {
return { success: false, message: `File size exceeds ${MAX_APKG_SIZE / (1024 * 1024)}MB limit` };
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer);
@@ -229,14 +237,14 @@ export async function actionPreviewApkg(formData: FormData): Promise<ActionOutpu
return {
success: true,
message: "APKG parsed successfully",
decks: decks.filter(d => d.cardCount > 0)
message: `Found ${decks.length} deck(s)`,
decks: decks.filter(d => d.cardCount > 0),
};
} catch (error) {
log.error("Failed to parse APKG", { error });
log.error("Failed to preview APKG", { error });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to parse APKG file"
message: error instanceof Error ? error.message : "Failed to parse APKG file",
};
}
}
@@ -261,22 +269,26 @@ export async function actionImportApkg(
return { success: false, message: "No deck selected" };
}
const deckId = parseInt(deckIdStr, 10);
if (isNaN(deckId)) {
const ankiDeckId = parseInt(deckIdStr, 10);
if (isNaN(ankiDeckId)) {
return { success: false, message: "Invalid deck ID" };
}
if (file.size > MAX_APKG_SIZE) {
return { success: false, message: `File size exceeds ${MAX_APKG_SIZE / (1024 * 1024)}MB limit` };
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer);
const result = await importDeck(parsed, deckId, session.user.id, deckName || undefined);
const result = await importDeck(parsed, ankiDeckId, session.user.id, deckName || undefined);
log.info("APKG imported successfully", {
userId: session.user.id,
deckId: result.deckId,
noteCount: result.noteCount,
cardCount: result.cardCount
cardCount: result.cardCount,
});
return {
@@ -290,7 +302,7 @@ export async function actionImportApkg(
log.error("Failed to import APKG", { error });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to import APKG file"
message: error instanceof Error ? error.message : "Failed to import APKG file",
};
}
}