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 JSZip from "jszip";
import initSqlJs from "sql.js"; import initSqlJs from "sql.js";
import type { Database } from "sql.js"; import type { Database } from "sql.js";
import { createHash } from "crypto";
import type { import type {
AnkiDeck, AnkiDeck,
AnkiNoteType, AnkiNoteType,
@@ -10,30 +11,21 @@ import type {
AnkiRevlogRow, AnkiRevlogRow,
} from "./types"; } from "./types";
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~"; const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
function generateGuid(): string { function generateGuid(): string {
let result = ""; let guid = "";
const id = Date.now() ^ (Math.random() * 0xffffffff); const bytes = new Uint8Array(10);
let num = BigInt(id); crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
result = BASE91_CHARS[Number(num % 91n)] + result; guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
num = num / 91n;
} }
return guid;
return result;
} }
function checksum(str: string): number { function checksum(text: string): number {
let hash = 0; const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
for (let i = 0; i < str.length; i++) { return parseInt(hash.substring(0, 8), 16);
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 100000000;
} }
function createCollectionSql(): string { function createCollectionSql(): string {
@@ -198,205 +190,208 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const db = new SQL.Database(); const db = new SQL.Database();
db.run(createCollectionSql()); try {
db.run(createCollectionSql());
const now = Date.now(); const now = Date.now();
const nowSeconds = Math.floor(now / 1000); const nowSeconds = Math.floor(now / 1000);
const defaultConfig = { const defaultConfig = {
dueCounts: true, dueCounts: true,
estTimes: true, estTimes: true,
newSpread: 0, newSpread: 0,
curDeck: data.deck.id, curDeck: data.deck.id,
curModel: data.noteType.id, curModel: data.noteType.id,
}; };
const deckJson: Record<string, AnkiDeck> = { const deckJson: Record<string, AnkiDeck> = {
[data.deck.id.toString()]: { [data.deck.id.toString()]: {
id: data.deck.id, id: data.deck.id,
mod: nowSeconds, mod: nowSeconds,
name: data.deck.name, name: data.deck.name,
usn: -1, usn: -1,
lrnToday: [0, 0], lrnToday: [0, 0],
revToday: [0, 0], revToday: [0, 0],
newToday: [0, 0], newToday: [0, 0],
timeToday: [0, 0], timeToday: [0, 0],
collapsed: data.deck.collapsed, collapsed: data.deck.collapsed,
browserCollapsed: false, browserCollapsed: false,
desc: data.deck.desc, desc: data.deck.desc,
dyn: 0, dyn: 0,
conf: 1, conf: 1,
extendNew: 0, extendNew: 0,
extendRev: 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,
}, },
rev: { "1": {
bury: true, id: 1,
ease4: 1.3, mod: nowSeconds,
ivlFct: 1, name: "Default",
maxIvl: 36500, usn: -1,
perDay: 200, lrnToday: [0, 0],
hardFactor: 1.2, 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, const noteTypeJson: Record<string, AnkiNoteType> = {
leechFails: 8, [data.noteType.id.toString()]: {
minInt: 1, id: data.noteType.id,
mult: 0, 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( const deckConfigJson: Record<string, AnkiDeckConfig> = {
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags) "1": {
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`, id: 1,
[ mod: nowSeconds,
nowSeconds, name: "Default",
now, usn: -1,
now, maxTaken: 60,
JSON.stringify(defaultConfig), autoplay: true,
JSON.stringify(noteTypeJson), timer: 0,
JSON.stringify(deckJson), replayq: true,
JSON.stringify(deckConfigJson), 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( db.run(
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data) `INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`, VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
[ [
Number(note.id),
note.guid || generateGuid(),
data.noteType.id,
nowSeconds, nowSeconds,
-1, now,
note.tags || " ", now,
note.flds, JSON.stringify(defaultConfig),
note.sfld, JSON.stringify(noteTypeJson),
note.csum || checksum(note.sfld), 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> { 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 anki21 = zip.file("collection.anki21");
const anki2 = zip.file("collection.anki2"); const anki2 = zip.file("collection.anki2");
let dbFile = anki21b || anki21 || anki2; const dbFile = anki21b || anki21 || anki2;
if (!dbFile) return null; if (!dbFile) return null;
const dbData = await dbFile.async("uint8array"); 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[] { function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
const results: T[] = [];
const stmt = db.prepare(sql); const stmt = db.prepare(sql);
stmt.bind(params); try {
stmt.bind(params);
while (stmt.step()) { const results: T[] = [];
const row = stmt.getAsObject(); while (stmt.step()) {
results.push(row as T); 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 { 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"); throw new Error("No valid Anki database found in APKG file");
} }
const col = queryOne<{ try {
crt: number; const col = queryOne<{
mod: number; crt: number;
ver: number; mod: number;
conf: string; ver: number;
models: string; conf: string;
decks: string; models: string;
dconf: string; decks: string;
tags: string; dconf: string;
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1"); tags: string;
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
if (!col) { if (!col) {
db.close(); throw new Error("Invalid APKG: no collection row found");
throw new Error("Invalid APKG: no collection row found"); }
}
const decksMap = new Map<number, AnkiDeck>(); const decksMap = new Map<number, AnkiDeck>();
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks); const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
for (const [id, deck] of Object.entries(decksJson)) { for (const [id, deck] of Object.entries(decksJson)) {
decksMap.set(parseInt(id, 10), deck); decksMap.set(parseInt(id, 10), deck);
} }
const noteTypesMap = new Map<number, AnkiNoteType>(); const noteTypesMap = new Map<number, AnkiNoteType>();
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models); const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
for (const [id, model] of Object.entries(modelsJson)) { for (const [id, model] of Object.entries(modelsJson)) {
noteTypesMap.set(parseInt(id, 10), model); noteTypesMap.set(parseInt(id, 10), model);
} }
const deckConfigsMap = new Map<number, AnkiDeckConfig>(); const deckConfigsMap = new Map<number, AnkiDeckConfig>();
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf); const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
for (const [id, config] of Object.entries(dconfJson)) { for (const [id, config] of Object.entries(dconfJson)) {
deckConfigsMap.set(parseInt(id, 10), config); deckConfigsMap.set(parseInt(id, 10), config);
} }
const notes = queryAll<AnkiNoteRow>( const notes = queryAll<AnkiNoteRow>(
db, db,
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes" "SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
); );
const cards = queryAll<AnkiCardRow>( const cards = queryAll<AnkiCardRow>(
db, db,
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards" "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>( const revlogs = queryAll<AnkiRevlogRow>(
db, db,
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog" "SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
); );
const mediaMap = new Map<string, Buffer>(); const mediaMap = new Map<string, Buffer>();
const mediaFile = zip.file("media"); const mediaFile = zip.file("media");
if (mediaFile) { if (mediaFile) {
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text")); const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
for (const [num, filename] of Object.entries(mediaJson)) { for (const [num, filename] of Object.entries(mediaJson)) {
const mediaData = zip.file(num); const mediaData = zip.file(num);
if (mediaData) { if (mediaData) {
const data = await mediaData.async("nodebuffer"); const data = await mediaData.async("nodebuffer");
mediaMap.set(filename, data); 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( export function getDeckNotesAndCards(

View File

@@ -2,21 +2,24 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { validate } from "@/utils/validate";
import { z } from "zod";
import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser"; import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums"; import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { repoGenerateGuid, repoCalculateCsum } from "@/modules/note/note-repository";
import type { ParsedApkg } from "@/lib/anki/types"; import type { ParsedApkg } from "@/lib/anki/types";
import { randomBytes } from "crypto";
const log = createLogger("import-action"); const log = createLogger("import-action");
const schemaImportApkg = z.object({ const MAX_APKG_SIZE = 100 * 1024 * 1024;
deckName: z.string().min(1).optional(),
});
export type ActionInputImportApkg = z.infer<typeof schemaImportApkg>; export interface ActionOutputPreviewApkg {
success: boolean;
message: string;
decks?: { id: number; name: string; cardCount: number }[];
}
export interface ActionOutputImportApkg { export interface ActionOutputImportApkg {
success: boolean; success: boolean;
@@ -26,12 +29,6 @@ export interface ActionOutputImportApkg {
cardCount?: number; cardCount?: number;
} }
export interface ActionOutputPreviewApkg {
success: boolean;
message: string;
decks?: { id: number; name: string; cardCount: number }[];
}
async function importNoteType( async function importNoteType(
parsed: ParsedApkg, parsed: ParsedApkg,
ankiNoteTypeId: number, ankiNoteTypeId: number,
@@ -75,8 +72,8 @@ async function importNoteType(
name: ankiNoteType.name, name: ankiNoteType.name,
kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD, kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD,
css: ankiNoteType.css, css: ankiNoteType.css,
fields: fields as unknown as object, fields: JSON.parse(JSON.stringify(fields)),
templates: templates as unknown as object, templates: JSON.parse(JSON.stringify(templates)),
userId, 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( async function importDeck(
parsed: ParsedApkg, parsed: ParsedApkg,
deckId: number, ankiDeckId: number,
userId: string, userId: string,
deckNameOverride?: string deckName?: string
): Promise<{ deckId: number; noteCount: number; cardCount: number }> { ): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
const ankiDeck = parsed.decks.get(deckId); const ankiDeck = parsed.decks.get(ankiDeckId);
if (!ankiDeck) { 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);
data: {
name: deckNameOverride || ankiDeck.name, const result = await prisma.$transaction(async (tx) => {
desc: ankiDeck.desc || "", const deck = await tx.deck.create({
visibility: "PRIVATE", data: {
collapsed: ankiDeck.collapsed, name: deckName || ankiDeck.name,
conf: JSON.parse(JSON.stringify(ankiDeck)), desc: ankiDeck.desc,
userId, 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); return result;
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 };
} }
export async function actionPreviewApkg(formData: FormData): Promise<ActionOutputPreviewApkg> { 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" }; 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 { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer); const parsed = await parseApkg(buffer);
@@ -229,14 +237,14 @@ export async function actionPreviewApkg(formData: FormData): Promise<ActionOutpu
return { return {
success: true, success: true,
message: "APKG parsed successfully", message: `Found ${decks.length} deck(s)`,
decks: decks.filter(d => d.cardCount > 0) decks: decks.filter(d => d.cardCount > 0),
}; };
} catch (error) { } catch (error) {
log.error("Failed to parse APKG", { error }); log.error("Failed to preview APKG", { error });
return { return {
success: false, 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" }; return { success: false, message: "No deck selected" };
} }
const deckId = parseInt(deckIdStr, 10); const ankiDeckId = parseInt(deckIdStr, 10);
if (isNaN(deckId)) { if (isNaN(ankiDeckId)) {
return { success: false, message: "Invalid deck ID" }; 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 { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer); 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", { log.info("APKG imported successfully", {
userId: session.user.id, userId: session.user.id,
deckId: result.deckId, deckId: result.deckId,
noteCount: result.noteCount, noteCount: result.noteCount,
cardCount: result.cardCount cardCount: result.cardCount,
}); });
return { return {
@@ -290,7 +302,7 @@ export async function actionImportApkg(
log.error("Failed to import APKG", { error }); log.error("Failed to import APKG", { error });
return { return {
success: false, success: false,
message: error instanceof Error ? error.message : "Failed to import APKG file" message: error instanceof Error ? error.message : "Failed to import APKG file",
}; };
} }
} }