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:
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user