From cbb9326f8492fbeb2895ebcbfe481be4d801848c Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Fri, 13 Mar 2026 15:10:34 +0800 Subject: [PATCH] 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 --- src/lib/anki/apkg-exporter.ts | 409 ++++++++++++++-------------- src/lib/anki/apkg-parser.ts | 171 ++++++------ src/modules/import/import-action.ts | 252 +++++++++-------- 3 files changed, 420 insertions(+), 412 deletions(-) diff --git a/src/lib/anki/apkg-exporter.ts b/src/lib/anki/apkg-exporter.ts index be21c09..2ba0f7b 100644 --- a/src/lib/anki/apkg-exporter.ts +++ b/src/lib/anki/apkg-exporter.ts @@ -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 { const db = new SQL.Database(); - db.run(createCollectionSql()); - - 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 deckJson: Record = { - [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 = { - [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 = { - "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, + try { + db.run(createCollectionSql()); + + 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 deckJson: Record = { + [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 = { + [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), - ] - ); - - for (const note of data.notes) { + }; + + const deckConfigJson: Record = { + "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, + }, + }; + 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 { diff --git a/src/lib/anki/apkg-parser.ts b/src/lib/anki/apkg-parser.ts index 659f698..089e22b 100644 --- a/src/lib/anki/apkg-parser.ts +++ b/src/lib/anki/apkg-parser.ts @@ -20,7 +20,7 @@ async function openDatabase(zip: JSZip): Promise { 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(jsonStr: string): T { } function queryAll(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(db: Database, sql: string, params: SqlValue[] = []): T | null { @@ -62,84 +62,85 @@ export async function parseApkg(buffer: Buffer): Promise { 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"); - - if (!col) { - db.close(); - throw new Error("Invalid APKG: no collection row found"); - } - - const decksMap = new Map(); - const decksJson = parseJsonField>(col.decks); - for (const [id, deck] of Object.entries(decksJson)) { - decksMap.set(parseInt(id, 10), deck); - } - - const noteTypesMap = new Map(); - const modelsJson = parseJsonField>(col.models); - for (const [id, model] of Object.entries(modelsJson)) { - noteTypesMap.set(parseInt(id, 10), model); - } - - const deckConfigsMap = new Map(); - const dconfJson = parseJsonField>(col.dconf); - for (const [id, config] of Object.entries(dconfJson)) { - deckConfigsMap.set(parseInt(id, 10), config); - } - - const notes = queryAll( - db, - "SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes" - ); - - const cards = queryAll( - 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( - db, - "SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog" - ); - - const mediaMap = new Map(); - const mediaFile = zip.file("media"); - if (mediaFile) { - const mediaJson = parseJsonField>(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); + 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) { + throw new Error("Invalid APKG: no collection row found"); + } + + const decksMap = new Map(); + const decksJson = parseJsonField>(col.decks); + for (const [id, deck] of Object.entries(decksJson)) { + decksMap.set(parseInt(id, 10), deck); + } + + const noteTypesMap = new Map(); + const modelsJson = parseJsonField>(col.models); + for (const [id, model] of Object.entries(modelsJson)) { + noteTypesMap.set(parseInt(id, 10), model); + } + + const deckConfigsMap = new Map(); + const dconfJson = parseJsonField>(col.dconf); + for (const [id, config] of Object.entries(dconfJson)) { + deckConfigsMap.set(parseInt(id, 10), config); + } + + const notes = queryAll( + db, + "SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes" + ); + + const cards = queryAll( + 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( + db, + "SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog" + ); + + const mediaMap = new Map(); + const mediaFile = zip.file("media"); + if (mediaFile) { + const mediaJson = parseJsonField>(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( diff --git a/src/modules/import/import-action.ts b/src/modules/import/import-action.ts index fc09054..331b599 100644 --- a/src/modules/import/import-action.ts +++ b/src/modules/import/import-action.ts @@ -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; +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(); + const noteIdMap = new Map(); + + 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(); - const firstNote = ankiNotes[0]; - if (firstNote) { - const importedNoteTypeId = await importNoteType(parsed, firstNote.mid, userId); - noteTypeIdMap.set(firstNote.mid, importedNoteTypeId); - } - - const noteIdMap = new Map(); - - 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 { @@ -222,21 +226,25 @@ export async function actionPreviewApkg(formData: FormData): Promise 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 decks = getDeckNames(parsed); - return { - success: true, - message: "APKG parsed successfully", - decks: decks.filter(d => d.cardCount > 0) + return { + success: true, + message: `Found ${decks.length} deck(s)`, + decks: decks.filter(d => d.cardCount > 0), }; } catch (error) { - log.error("Failed to parse APKG", { error }); - return { - success: false, - message: error instanceof Error ? error.message : "Failed to parse APKG file" + log.error("Failed to preview APKG", { error }); + return { + success: false, + 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); - log.info("APKG imported successfully", { - userId: session.user.id, + 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 { @@ -288,9 +300,9 @@ export async function actionImportApkg( }; } catch (error) { log.error("Failed to import APKG", { error }); - return { - success: false, - message: error instanceof Error ? error.message : "Failed to import APKG file" + return { + success: false, + message: error instanceof Error ? error.message : "Failed to import APKG file", }; } }