aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/anki/parser.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/anki/parser.ts')
-rw-r--r--src/server/anki/parser.ts268
1 files changed, 268 insertions, 0 deletions
diff --git a/src/server/anki/parser.ts b/src/server/anki/parser.ts
index c317ce7..3c961ca 100644
--- a/src/server/anki/parser.ts
+++ b/src/server/anki/parser.ts
@@ -372,3 +372,271 @@ export async function listAnkiPackageContents(
const entries = await parseZip(filePath);
return Array.from(entries.keys());
}
+
+/**
+ * Represents a Kioku deck ready for import
+ */
+export interface KiokuDeck {
+ name: string;
+ description: string | null;
+}
+
+/**
+ * Represents a Kioku card ready for import
+ */
+export interface KiokuCard {
+ front: string;
+ back: string;
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date | null;
+}
+
+/**
+ * Represents the import data with deck and cards
+ */
+export interface KiokuImportData {
+ deck: KiokuDeck;
+ cards: KiokuCard[];
+}
+
+/**
+ * Strip HTML tags from a string
+ */
+function stripHtml(html: string): string {
+ // Replace <br> and <br/> with newlines
+ let text = html.replace(/<br\s*\/?>/gi, "\n");
+ // Replace </div>, </p>, </li> with newlines
+ text = text.replace(/<\/(div|p|li)>/gi, "\n");
+ // Remove all other HTML tags
+ text = text.replace(/<[^>]*>/g, "");
+ // Decode common HTML entities
+ text = text
+ .replace(/&nbsp;/g, " ")
+ .replace(/&amp;/g, "&")
+ .replace(/&lt;/g, "<")
+ .replace(/&gt;/g, ">")
+ .replace(/&quot;/g, '"')
+ .replace(/&#39;/g, "'")
+ .replace(/&#x27;/g, "'");
+ // Normalize whitespace
+ text = text.replace(/\n\s*\n/g, "\n").trim();
+ return text;
+}
+
+/**
+ * Convert Anki factor (0-10000) to FSRS difficulty (0-10)
+ * Anki factor: 2500 = 250% ease = easy, lower = harder
+ * FSRS difficulty: higher = harder
+ */
+function ankiFactorToFsrsDifficulty(factor: number): number {
+ // Default Anki factor is 2500 (250% ease)
+ // Range is typically 1300-2500+ (130% to 250%+)
+ // FSRS difficulty is 0-10 where higher means harder
+
+ if (factor === 0) {
+ // New card, use default FSRS difficulty
+ return 0;
+ }
+
+ // Convert: high factor (easy) -> low difficulty, low factor (hard) -> high difficulty
+ // Map factor range [1300, 3500] to difficulty [8, 2]
+ const minFactor = 1300;
+ const maxFactor = 3500;
+ const minDifficulty = 2;
+ const maxDifficulty = 8;
+
+ const clampedFactor = Math.max(minFactor, Math.min(maxFactor, factor));
+ const normalized = (clampedFactor - minFactor) / (maxFactor - minFactor);
+ // Invert: high factor -> low difficulty
+ const difficulty =
+ maxDifficulty - normalized * (maxDifficulty - minDifficulty);
+
+ return Math.round(difficulty * 100) / 100;
+}
+
+/**
+ * Estimate FSRS stability from Anki interval
+ * Stability in FSRS roughly corresponds to the interval in days
+ */
+function ankiIntervalToFsrsStability(ivl: number, state: number): number {
+ // For new cards, stability is 0
+ if (state === 0) {
+ return 0;
+ }
+
+ // For learning/relearning cards, use a small initial stability
+ if (state === 1 || state === 3) {
+ return Math.max(0.5, ivl);
+ }
+
+ // For review cards, stability approximates the interval
+ return Math.max(1, ivl);
+}
+
+/**
+ * Convert Anki due timestamp to a Date
+ * Anki stores due differently based on card type:
+ * - New cards: due is a position in the new queue (integer)
+ * - Learning cards: due is Unix timestamp in seconds
+ * - Review cards: due is days since collection creation
+ */
+function ankiDueToDate(
+ due: number,
+ cardType: number,
+ collectionCreation?: number,
+): Date {
+ const now = new Date();
+
+ if (cardType === 0) {
+ // New card: due is queue position, return current time
+ return now;
+ }
+
+ if (cardType === 1 || cardType === 3) {
+ // Learning/Relearning: due is Unix timestamp in seconds
+ if (due > 100000000) {
+ // Sanity check for timestamp
+ return new Date(due * 1000);
+ }
+ return now;
+ }
+
+ // Review card: due is days since collection creation
+ if (collectionCreation) {
+ const baseDate = new Date(collectionCreation * 1000);
+ baseDate.setDate(baseDate.getDate() + due);
+ return baseDate;
+ }
+
+ // Fallback: treat as days from now (roughly)
+ const dueDate = new Date();
+ dueDate.setDate(dueDate.getDate() + due);
+ return dueDate;
+}
+
+/**
+ * Convert an Anki package to Kioku import format
+ * Groups cards by deck and maps note fields to front/back
+ *
+ * @param pkg The parsed Anki package
+ * @param options Optional configuration for the mapping
+ * @returns Array of decks with their cards ready for import
+ */
+export function mapAnkiToKioku(
+ pkg: AnkiPackage,
+ options?: {
+ /** Skip the default Anki deck (id: 1) */
+ skipDefaultDeck?: boolean;
+ /** Collection creation timestamp (for accurate due date calculation) */
+ collectionCreation?: number;
+ },
+): KiokuImportData[] {
+ const skipDefaultDeck = options?.skipDefaultDeck ?? true;
+ const collectionCreation = options?.collectionCreation;
+
+ // Build lookup maps
+ const noteById = new Map<number, AnkiNote>();
+ for (const note of pkg.notes) {
+ noteById.set(note.id, note);
+ }
+
+ const modelById = new Map<number, AnkiModel>();
+ for (const model of pkg.models) {
+ modelById.set(model.id, model);
+ }
+
+ const deckById = new Map<number, AnkiDeck>();
+ for (const deck of pkg.decks) {
+ deckById.set(deck.id, deck);
+ }
+
+ // Group cards by deck
+ const cardsByDeck = new Map<number, AnkiCard[]>();
+ for (const card of pkg.cards) {
+ const existing = cardsByDeck.get(card.did) || [];
+ existing.push(card);
+ cardsByDeck.set(card.did, existing);
+ }
+
+ const result: KiokuImportData[] = [];
+
+ for (const [deckId, ankiCards] of cardsByDeck) {
+ // Skip default deck if configured
+ if (skipDefaultDeck && deckId === 1) {
+ continue;
+ }
+
+ const ankiDeck = deckById.get(deckId);
+ if (!ankiDeck) {
+ continue;
+ }
+
+ const kiokuDeck: KiokuDeck = {
+ name: ankiDeck.name,
+ description: ankiDeck.description || null,
+ };
+
+ const kiokuCards: KiokuCard[] = [];
+
+ for (const ankiCard of ankiCards) {
+ const note = noteById.get(ankiCard.nid);
+ if (!note) {
+ continue;
+ }
+
+ const model = modelById.get(note.mid);
+
+ // Get front and back fields
+ // For Basic model: fields[0] = Front, fields[1] = Back
+ // For other models, we try to use the first two fields
+ let front = note.fields[0] || "";
+ let back = note.fields[1] || "";
+
+ // If there's a template, try to identify question/answer fields
+ if (model && model.templates.length > 0) {
+ const template = model.templates[ankiCard.ord] || model.templates[0];
+ if (template) {
+ // Use template hints if available
+ // For now, we just use the first two fields as front/back
+ // A more sophisticated approach would parse the template
+ }
+ }
+
+ // Strip HTML from fields
+ front = stripHtml(front);
+ back = stripHtml(back);
+
+ // Map card state (Anki and FSRS use the same values: 0=New, 1=Learning, 2=Review, 3=Relearning)
+ const state = ankiCard.type;
+
+ const kiokuCard: KiokuCard = {
+ front,
+ back,
+ state,
+ due: ankiDueToDate(ankiCard.due, ankiCard.type, collectionCreation),
+ stability: ankiIntervalToFsrsStability(ankiCard.ivl, ankiCard.type),
+ difficulty: ankiFactorToFsrsDifficulty(ankiCard.factor),
+ elapsedDays: ankiCard.ivl > 0 ? ankiCard.ivl : 0,
+ scheduledDays: ankiCard.ivl,
+ reps: ankiCard.reps,
+ lapses: ankiCard.lapses,
+ lastReview: ankiCard.reps > 0 ? new Date() : null, // We don't have exact last review time
+ };
+
+ kiokuCards.push(kiokuCard);
+ }
+
+ if (kiokuCards.length > 0) {
+ result.push({ deck: kiokuDeck, cards: kiokuCards });
+ }
+ }
+
+ return result;
+}