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(text: string): number {
} const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
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 createCollectionSql(): string { function createCollectionSql(): string {
@@ -198,6 +190,7 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const db = new SQL.Database(); const db = new SQL.Database();
try {
db.run(createCollectionSql()); db.run(createCollectionSql());
const now = Date.now(); const now = Date.now();
@@ -273,13 +266,15 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
ord: t.ord, ord: t.ord,
qfmt: t.qfmt, qfmt: t.qfmt,
afmt: t.afmt, afmt: t.afmt,
bqfmt: "",
bafmt: "",
did: null, did: null,
})), })),
css: data.noteType.css, 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", 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}", latexPost: "\\end{document}",
latexsvg: false, 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(); 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);
try {
stmt.bind(params); stmt.bind(params);
const results: T[] = [];
while (stmt.step()) { while (stmt.step()) {
const row = stmt.getAsObject(); results.push(stmt.getAsObject() as T);
results.push(row as T);
} }
stmt.free();
return results; return results;
} finally {
stmt.free();
}
} }
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null { 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"); throw new Error("No valid Anki database found in APKG file");
} }
try {
const col = queryOne<{ const col = queryOne<{
crt: number; crt: number;
mod: 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"); }>(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");
} }
@@ -124,8 +124,6 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
} }
} }
db.close();
return { return {
decks: decksMap, decks: decksMap,
noteTypes: noteTypesMap, noteTypes: noteTypesMap,
@@ -140,6 +138,9 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
ver: col.ver, ver: col.ver,
}, },
}; };
} finally {
db.close();
}
} }
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,41 +105,42 @@ function mapAnkiCardQueue(queue: number): CardQueue {
} }
} }
async function importDeck( function generateUniqueId(): bigint {
parsed: ParsedApkg, const bytes = randomBytes(8);
deckId: number, const timestamp = BigInt(Date.now());
userId: string, const random = BigInt(`0x${bytes.toString("hex")}`);
deckNameOverride?: string return timestamp ^ random;
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
const ankiDeck = parsed.decks.get(deckId);
if (!ankiDeck) {
throw new Error(`Deck ${deckId} not found in APKG`);
} }
const deck = await prisma.deck.create({ async function importDeck(
parsed: ParsedApkg,
ankiDeckId: number,
userId: string,
deckName?: string
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
const ankiDeck = parsed.decks.get(ankiDeckId);
if (!ankiDeck) {
throw new Error(`Deck ${ankiDeckId} not found in APKG`);
}
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, ankiDeckId);
const result = await prisma.$transaction(async (tx) => {
const deck = await tx.deck.create({
data: { data: {
name: deckNameOverride || ankiDeck.name, name: deckName || ankiDeck.name,
desc: ankiDeck.desc || "", desc: ankiDeck.desc,
visibility: "PRIVATE",
collapsed: ankiDeck.collapsed,
conf: JSON.parse(JSON.stringify(ankiDeck)),
userId, userId,
collapsed: ankiDeck.collapsed,
conf: {},
}, },
}); });
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, deckId);
if (ankiNotes.length === 0) { if (ankiNotes.length === 0) {
return { deckId: deck.id, noteCount: 0, cardCount: 0 }; return { deckId: deck.id, noteCount: 0, cardCount: 0 };
} }
const noteTypeIdMap = new Map<number, number>(); 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>(); const noteIdMap = new Map<number, bigint>();
for (const ankiNote of ankiNotes) { for (const ankiNote of ankiNotes) {
@@ -152,20 +150,23 @@ async function importDeck(
noteTypeIdMap.set(ankiNote.mid, noteTypeId); noteTypeIdMap.set(ankiNote.mid, noteTypeId);
} }
const noteId = BigInt(Date.now() + Math.floor(Math.random() * 1000)); const noteId = generateUniqueId();
noteIdMap.set(ankiNote.id, noteId); 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: { data: {
id: noteId, id: noteId,
guid: ankiNote.guid, guid,
noteTypeId, noteTypeId,
mod: ankiNote.mod, mod: ankiNote.mod,
usn: ankiNote.usn, usn: ankiNote.usn,
tags: ankiNote.tags, tags: ankiNote.tags,
flds: ankiNote.flds, flds: ankiNote.flds,
sfld: ankiNote.sfld, sfld: ankiNote.sfld,
csum: ankiNote.csum, csum,
flags: ankiNote.flags, flags: ankiNote.flags,
data: ankiNote.data, data: ankiNote.data,
userId, userId,
@@ -180,9 +181,9 @@ async function importDeck(
continue; continue;
} }
await prisma.card.create({ await tx.card.create({
data: { data: {
id: BigInt(ankiCard.id), id: generateUniqueId(),
noteId, noteId,
deckId: deck.id, deckId: deck.id,
ord: ankiCard.ord, ord: ankiCard.ord,
@@ -205,6 +206,9 @@ async function importDeck(
} }
return { deckId: deck.id, noteCount: ankiNotes.length, cardCount: ankiCards.length }; return { deckId: deck.id, noteCount: ankiNotes.length, cardCount: ankiCards.length };
});
return result;
} }
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",
}; };
} }
} }