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>
This commit is contained in:
2026-03-13 15:10:34 +08:00
parent 49ad953add
commit cbb9326f84
3 changed files with 420 additions and 412 deletions

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,205 +190,208 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const db = new SQL.Database();
db.run(createCollectionSql());
try {
db.run(createCollectionSql());
const now = Date.now();
const nowSeconds = Math.floor(now / 1000);
const now = Date.now();
const nowSeconds = Math.floor(now / 1000);
const defaultConfig = {
dueCounts: true,
estTimes: true,
newSpread: 0,
curDeck: data.deck.id,
curModel: data.noteType.id,
};
const defaultConfig = {
dueCounts: true,
estTimes: true,
newSpread: 0,
curDeck: data.deck.id,
curModel: data.noteType.id,
};
const deckJson: Record<string, AnkiDeck> = {
[data.deck.id.toString()]: {
id: data.deck.id,
mod: nowSeconds,
name: data.deck.name,
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: data.deck.collapsed,
browserCollapsed: false,
desc: data.deck.desc,
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: false,
browserCollapsed: false,
desc: "",
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
};
const noteTypeJson: Record<string, AnkiNoteType> = {
[data.noteType.id.toString()]: {
id: data.noteType.id,
name: data.noteType.name,
type: data.noteType.kind === "CLOZE" ? 1 : 0,
mod: nowSeconds,
usn: -1,
sortf: 0,
did: data.deck.id,
flds: data.noteType.fields.map((f, i) => ({
id: now + i,
name: f.name,
ord: f.ord,
sticky: false,
rtl: false,
font: "Arial",
size: 20,
media: [],
})),
tmpls: data.noteType.templates.map((t, i) => ({
id: now + i + 100,
name: t.name,
ord: t.ord,
qfmt: t.qfmt,
afmt: t.afmt,
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: [],
},
};
const deckConfigJson: Record<string, AnkiDeckConfig> = {
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
maxTaken: 60,
autoplay: true,
timer: 0,
replayq: true,
new: {
bury: true,
delays: [1, 10],
initialFactor: 2500,
ints: [1, 4, 7],
order: 1,
perDay: 20,
const deckJson: Record<string, AnkiDeck> = {
[data.deck.id.toString()]: {
id: data.deck.id,
mod: nowSeconds,
name: data.deck.name,
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: data.deck.collapsed,
browserCollapsed: false,
desc: data.deck.desc,
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
rev: {
bury: true,
ease4: 1.3,
ivlFct: 1,
maxIvl: 36500,
perDay: 200,
hardFactor: 1.2,
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: false,
browserCollapsed: false,
desc: "",
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
lapse: {
delays: [10],
leechAction: 0,
leechFails: 8,
minInt: 1,
mult: 0,
};
const noteTypeJson: Record<string, AnkiNoteType> = {
[data.noteType.id.toString()]: {
id: data.noteType.id,
name: data.noteType.name,
type: data.noteType.kind === "CLOZE" ? 1 : 0,
mod: nowSeconds,
usn: -1,
sortf: 0,
did: data.deck.id,
flds: data.noteType.fields.map((f, i) => ({
id: now + i,
name: f.name,
ord: f.ord,
sticky: false,
rtl: false,
font: "Arial",
size: 20,
media: [],
})),
tmpls: data.noteType.templates.map((t, i) => ({
id: now + i + 100,
name: t.name,
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: [[0, "any", [0]]],
},
dyn: false,
},
};
};
db.run(
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
[
nowSeconds,
now,
now,
JSON.stringify(defaultConfig),
JSON.stringify(noteTypeJson),
JSON.stringify(deckJson),
JSON.stringify(deckConfigJson),
]
);
const deckConfigJson: Record<string, AnkiDeckConfig> = {
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
maxTaken: 60,
autoplay: true,
timer: 0,
replayq: true,
new: {
bury: true,
delays: [1, 10],
initialFactor: 2500,
ints: [1, 4, 7],
order: 1,
perDay: 20,
},
rev: {
bury: true,
ease4: 1.3,
ivlFct: 1,
maxIvl: 36500,
perDay: 200,
hardFactor: 1.2,
},
lapse: {
delays: [10],
leechAction: 0,
leechFails: 8,
minInt: 1,
mult: 0,
},
dyn: false,
},
};
for (const note of data.notes) {
db.run(
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
[
Number(note.id),
note.guid || generateGuid(),
data.noteType.id,
nowSeconds,
-1,
note.tags || " ",
note.flds,
note.sfld,
note.csum || checksum(note.sfld),
now,
now,
JSON.stringify(defaultConfig),
JSON.stringify(noteTypeJson),
JSON.stringify(deckJson),
JSON.stringify(deckConfigJson),
]
);
for (const note of data.notes) {
db.run(
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
[
Number(note.id),
note.guid || generateGuid(),
data.noteType.id,
nowSeconds,
-1,
note.tags || " ",
note.flds,
note.sfld,
note.csum || checksum(note.sfld),
]
);
}
for (const card of data.cards) {
db.run(
`INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, '')`,
[
Number(card.id),
Number(card.noteId),
data.deck.id,
card.ord,
nowSeconds,
-1,
mapCardType(card.type),
mapCardQueue(card.queue),
card.due,
card.ivl,
card.factor,
card.reps,
card.lapses,
card.left,
]
);
}
for (const revlog of data.revlogs) {
db.run(
`INSERT INTO revlog (id, cid, usn, ease, ivl, lastIvl, factor, time, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
Number(revlog.id),
Number(revlog.cardId),
-1,
revlog.ease,
revlog.ivl,
revlog.lastIvl,
revlog.factor,
revlog.time,
revlog.type,
]
);
}
return db.export();
} finally {
db.close();
}
for (const card of data.cards) {
db.run(
`INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, '')`,
[
Number(card.id),
Number(card.noteId),
data.deck.id,
card.ord,
nowSeconds,
-1,
mapCardType(card.type),
mapCardQueue(card.queue),
card.due,
card.ivl,
card.factor,
card.reps,
card.lapses,
card.left,
]
);
}
for (const revlog of data.revlogs) {
db.run(
`INSERT INTO revlog (id, cid, usn, ease, ivl, lastIvl, factor, time, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
Number(revlog.id),
Number(revlog.cardId),
-1,
revlog.ease,
revlog.ivl,
revlog.lastIvl,
revlog.factor,
revlog.time,
revlog.type,
]
);
}
const dbData = db.export();
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);
stmt.bind(params);
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as T);
try {
stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
results.push(stmt.getAsObject() as T);
}
return results;
} finally {
stmt.free();
}
stmt.free();
return results;
}
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
@@ -62,84 +62,85 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
throw new Error("No valid Anki database found in APKG file");
}
const col = queryOne<{
crt: number;
mod: number;
ver: number;
conf: string;
models: string;
decks: string;
dconf: string;
tags: string;
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
try {
const col = queryOne<{
crt: number;
mod: number;
ver: number;
conf: string;
models: string;
decks: string;
dconf: string;
tags: string;
}>(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");
}
if (!col) {
throw new Error("Invalid APKG: no collection row found");
}
const decksMap = new Map<number, AnkiDeck>();
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
for (const [id, deck] of Object.entries(decksJson)) {
decksMap.set(parseInt(id, 10), deck);
}
const decksMap = new Map<number, AnkiDeck>();
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
for (const [id, deck] of Object.entries(decksJson)) {
decksMap.set(parseInt(id, 10), deck);
}
const noteTypesMap = new Map<number, AnkiNoteType>();
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
for (const [id, model] of Object.entries(modelsJson)) {
noteTypesMap.set(parseInt(id, 10), model);
}
const noteTypesMap = new Map<number, AnkiNoteType>();
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
for (const [id, model] of Object.entries(modelsJson)) {
noteTypesMap.set(parseInt(id, 10), model);
}
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
for (const [id, config] of Object.entries(dconfJson)) {
deckConfigsMap.set(parseInt(id, 10), config);
}
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
for (const [id, config] of Object.entries(dconfJson)) {
deckConfigsMap.set(parseInt(id, 10), config);
}
const notes = queryAll<AnkiNoteRow>(
db,
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
);
const notes = queryAll<AnkiNoteRow>(
db,
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
);
const cards = queryAll<AnkiCardRow>(
db,
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
);
const cards = queryAll<AnkiCardRow>(
db,
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
);
const revlogs = queryAll<AnkiRevlogRow>(
db,
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
);
const revlogs = queryAll<AnkiRevlogRow>(
db,
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
);
const mediaMap = new Map<string, Buffer>();
const mediaFile = zip.file("media");
if (mediaFile) {
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
for (const [num, filename] of Object.entries(mediaJson)) {
const mediaData = zip.file(num);
if (mediaData) {
const data = await mediaData.async("nodebuffer");
mediaMap.set(filename, data);
const mediaMap = new Map<string, Buffer>();
const mediaFile = zip.file("media");
if (mediaFile) {
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
for (const [num, filename] of Object.entries(mediaJson)) {
const mediaData = zip.file(num);
if (mediaData) {
const data = await mediaData.async("nodebuffer");
mediaMap.set(filename, data);
}
}
}
return {
decks: decksMap,
noteTypes: noteTypesMap,
deckConfigs: deckConfigsMap,
notes,
cards,
revlogs,
media: mediaMap,
collectionMeta: {
crt: col.crt,
mod: col.mod,
ver: col.ver,
},
};
} finally {
db.close();
}
db.close();
return {
decks: decksMap,
noteTypes: noteTypesMap,
deckConfigs: deckConfigsMap,
notes,
cards,
revlogs,
media: mediaMap,
collectionMeta: {
crt: col.crt,
mod: col.mod,
ver: col.ver,
},
};
}
export function getDeckNotesAndCards(

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,103 +105,110 @@ 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({
data: {
name: deckNameOverride || ankiDeck.name,
desc: ankiDeck.desc || "",
visibility: "PRIVATE",
collapsed: ankiDeck.collapsed,
conf: JSON.parse(JSON.stringify(ankiDeck)),
userId,
},
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, ankiDeckId);
const result = await prisma.$transaction(async (tx) => {
const deck = await tx.deck.create({
data: {
name: deckName || ankiDeck.name,
desc: ankiDeck.desc,
userId,
collapsed: ankiDeck.collapsed,
conf: {},
},
});
if (ankiNotes.length === 0) {
return { deckId: deck.id, noteCount: 0, cardCount: 0 };
}
const noteTypeIdMap = new Map<number, number>();
const noteIdMap = new Map<number, bigint>();
for (const ankiNote of ankiNotes) {
let noteTypeId = noteTypeIdMap.get(ankiNote.mid);
if (!noteTypeId) {
noteTypeId = await importNoteType(parsed, ankiNote.mid, userId);
noteTypeIdMap.set(ankiNote.mid, noteTypeId);
}
const noteId = generateUniqueId();
noteIdMap.set(ankiNote.id, noteId);
const guid = ankiNote.guid || repoGenerateGuid();
const csum = ankiNote.csum || repoCalculateCsum(ankiNote.sfld);
await tx.note.create({
data: {
id: noteId,
guid,
noteTypeId,
mod: ankiNote.mod,
usn: ankiNote.usn,
tags: ankiNote.tags,
flds: ankiNote.flds,
sfld: ankiNote.sfld,
csum,
flags: ankiNote.flags,
data: ankiNote.data,
userId,
},
});
}
for (const ankiCard of ankiCards) {
const noteId = noteIdMap.get(ankiCard.nid);
if (!noteId) {
log.warn("Card references non-existent note", { cardId: ankiCard.id, noteId: ankiCard.nid });
continue;
}
await tx.card.create({
data: {
id: generateUniqueId(),
noteId,
deckId: deck.id,
ord: ankiCard.ord,
mod: ankiCard.mod,
usn: ankiCard.usn,
type: mapAnkiCardType(ankiCard.type),
queue: mapAnkiCardQueue(ankiCard.queue),
due: ankiCard.due,
ivl: ankiCard.ivl,
factor: ankiCard.factor,
reps: ankiCard.reps,
lapses: ankiCard.lapses,
left: ankiCard.left,
odue: ankiCard.odue,
odid: ankiCard.odid,
flags: ankiCard.flags,
data: ankiCard.data,
},
});
}
return { deckId: deck.id, noteCount: ankiNotes.length, cardCount: ankiCards.length };
});
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) {
let noteTypeId = noteTypeIdMap.get(ankiNote.mid);
if (!noteTypeId) {
noteTypeId = await importNoteType(parsed, ankiNote.mid, userId);
noteTypeIdMap.set(ankiNote.mid, noteTypeId);
}
const noteId = BigInt(Date.now() + Math.floor(Math.random() * 1000));
noteIdMap.set(ankiNote.id, noteId);
await prisma.note.create({
data: {
id: noteId,
guid: ankiNote.guid,
noteTypeId,
mod: ankiNote.mod,
usn: ankiNote.usn,
tags: ankiNote.tags,
flds: ankiNote.flds,
sfld: ankiNote.sfld,
csum: ankiNote.csum,
flags: ankiNote.flags,
data: ankiNote.data,
userId,
},
});
}
for (const ankiCard of ankiCards) {
const noteId = noteIdMap.get(ankiCard.nid);
if (!noteId) {
log.warn("Card references non-existent note", { cardId: ankiCard.id, noteId: ankiCard.nid });
continue;
}
await prisma.card.create({
data: {
id: BigInt(ankiCard.id),
noteId,
deckId: deck.id,
ord: ankiCard.ord,
mod: ankiCard.mod,
usn: ankiCard.usn,
type: mapAnkiCardType(ankiCard.type),
queue: mapAnkiCardQueue(ankiCard.queue),
due: ankiCard.due,
ivl: ankiCard.ivl,
factor: ankiCard.factor,
reps: ankiCard.reps,
lapses: ankiCard.lapses,
left: ankiCard.left,
odue: ankiCard.odue,
odid: ankiCard.odid,
flags: ankiCard.flags,
data: ankiCard.data,
},
});
}
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",
};
}
}