diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-15 22:34:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-15 22:34:33 +0900 |
| commit | adc30217b6fa5773f9fb96c6fb106102cd865a89 (patch) | |
| tree | 7c105c9c77b1bb85112a108f55d381b29f18497f /src | |
| parent | ced08d592e3d277044eb9bbfea1bef0e4e4285e3 (diff) | |
| download | kioku-adc30217b6fa5773f9fb96c6fb106102cd865a89.tar.gz kioku-adc30217b6fa5773f9fb96c6fb106102cd865a89.tar.zst kioku-adc30217b6fa5773f9fb96c6fb106102cd865a89.zip | |
feat(anki): add Note/Card mapping to Kioku format
Add mapAnkiToKioku function that converts parsed Anki packages to
Kioku's internal data format. Includes:
- HTML stripping and entity decoding for card fields
- Anki factor to FSRS difficulty conversion
- Anki interval to FSRS stability estimation
- Due date conversion for different card types
- Option to skip default Anki deck
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/server/anki/index.ts | 4 | ||||
| -rw-r--r-- | src/server/anki/parser.test.ts | 342 | ||||
| -rw-r--r-- | src/server/anki/parser.ts | 268 |
3 files changed, 614 insertions, 0 deletions
diff --git a/src/server/anki/index.ts b/src/server/anki/index.ts index 67e81de..13e6aa0 100644 --- a/src/server/anki/index.ts +++ b/src/server/anki/index.ts @@ -4,6 +4,10 @@ export { type AnkiModel, type AnkiNote, type AnkiPackage, + type KiokuCard, + type KiokuDeck, + type KiokuImportData, listAnkiPackageContents, + mapAnkiToKioku, parseAnkiPackage, } from "./parser.js"; diff --git a/src/server/anki/parser.test.ts b/src/server/anki/parser.test.ts index 61a6832..aeee62c 100644 --- a/src/server/anki/parser.test.ts +++ b/src/server/anki/parser.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type AnkiPackage, listAnkiPackageContents, + mapAnkiToKioku, parseAnkiPackage, } from "./parser.js"; @@ -523,4 +524,345 @@ describe("Anki Parser", () => { expect(files).toContain("test.txt"); }); }); + + describe("mapAnkiToKioku", () => { + let pkg: AnkiPackage; + + beforeAll(async () => { + pkg = await parseAnkiPackage(testApkgPath); + }); + + it("should map decks correctly", () => { + const result = mapAnkiToKioku(pkg); + + // Should have Test Deck (skipping Default deck by default) + expect(result.length).toBe(1); + + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + expect(testDeckData).toBeDefined(); + expect(testDeckData?.deck.description).toBe("A test deck"); + }); + + it("should include default deck when skipDefaultDeck is false", () => { + // Create a package with cards in both default and test decks + const pkgWithDefault: AnkiPackage = { + notes: [ + { + id: 1, + guid: "test1", + mid: 1, + mod: 0, + tags: [], + fields: ["Front1", "Back1"], + sfld: "Front1", + }, + { + id: 2, + guid: "test2", + mid: 1, + mod: 0, + tags: [], + fields: ["Front2", "Back2"], + sfld: "Front2", + }, + ], + cards: [ + { + id: 1, + nid: 1, + did: 1, // Default deck + ord: 0, + mod: 0, + type: 0, + queue: 0, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + }, + { + id: 2, + nid: 2, + did: 2, // Test deck + ord: 0, + mod: 0, + type: 0, + queue: 0, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + }, + ], + decks: [ + { id: 1, name: "Default", description: "" }, + { id: 2, name: "Test Deck", description: "" }, + ], + models: [ + { + id: 1, + name: "Basic", + fields: ["Front", "Back"], + templates: [ + { name: "Card 1", qfmt: "{{Front}}", afmt: "{{Back}}" }, + ], + }, + ], + }; + + // With skipDefaultDeck = true (default), should only have Test Deck + const resultSkip = mapAnkiToKioku(pkgWithDefault); + expect(resultSkip.length).toBe(1); + expect(resultSkip[0]?.deck.name).toBe("Test Deck"); + + // With skipDefaultDeck = false, should have both decks + const result = mapAnkiToKioku(pkgWithDefault, { skipDefaultDeck: false }); + const deckNames = result.map((d) => d.deck.name); + expect(deckNames).toContain("Default"); + expect(deckNames).toContain("Test Deck"); + }); + + it("should map note fields to front/back", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + expect(testDeckData?.cards.length).toBe(3); + + // Card 1: Hello/World + const card1 = testDeckData?.cards.find((c) => c.front === "Hello"); + expect(card1).toBeDefined(); + expect(card1?.back).toBe("World"); + + // Card 2: 日本語/Japanese + const card2 = testDeckData?.cards.find((c) => c.front === "日本語"); + expect(card2).toBeDefined(); + expect(card2?.back).toBe("Japanese"); + + // Card 3: Question/Answer + const card3 = testDeckData?.cards.find((c) => c.front === "Question"); + expect(card3).toBeDefined(); + expect(card3?.back).toBe("Answer"); + }); + + it("should map card states correctly", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + // New card (Hello/World) + const newCard = testDeckData?.cards.find((c) => c.front === "Hello"); + expect(newCard?.state).toBe(0); // New + + // Review card (日本語/Japanese) + const reviewCard = testDeckData?.cards.find((c) => c.front === "日本語"); + expect(reviewCard?.state).toBe(2); // Review + expect(reviewCard?.reps).toBe(5); + expect(reviewCard?.lapses).toBe(1); + + // Learning card (Question/Answer) + const learningCard = testDeckData?.cards.find( + (c) => c.front === "Question", + ); + expect(learningCard?.state).toBe(1); // Learning + }); + + it("should map scheduling data correctly", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + // Review card has interval of 30 days + const reviewCard = testDeckData?.cards.find((c) => c.front === "日本語"); + expect(reviewCard?.scheduledDays).toBe(30); + expect(reviewCard?.stability).toBe(30); // Stability approximates interval + expect(reviewCard?.elapsedDays).toBe(30); + + // New card has no interval + const newCard = testDeckData?.cards.find((c) => c.front === "Hello"); + expect(newCard?.scheduledDays).toBe(0); + expect(newCard?.stability).toBe(0); + expect(newCard?.elapsedDays).toBe(0); + }); + + it("should convert Anki factor to FSRS difficulty", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + // Review card has factor 2500 (default ease) + const reviewCard = testDeckData?.cards.find((c) => c.front === "日本語"); + // Factor 2500 should map to a moderate difficulty (around 5) + expect(reviewCard?.difficulty).toBeGreaterThan(0); + expect(reviewCard?.difficulty).toBeLessThan(10); + + // New card has factor 0, should have difficulty 0 + const newCard = testDeckData?.cards.find((c) => c.front === "Hello"); + expect(newCard?.difficulty).toBe(0); + }); + + it("should set due date for cards", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + // All cards should have valid due dates + for (const card of testDeckData?.cards || []) { + expect(card.due).toBeInstanceOf(Date); + expect(card.due.getTime()).not.toBeNaN(); + } + }); + + it("should set lastReview for reviewed cards", () => { + const result = mapAnkiToKioku(pkg); + const testDeckData = result.find((d) => d.deck.name === "Test Deck"); + + // Review card has been reviewed + const reviewCard = testDeckData?.cards.find((c) => c.front === "日本語"); + expect(reviewCard?.lastReview).toBeInstanceOf(Date); + + // New card has not been reviewed + const newCard = testDeckData?.cards.find((c) => c.front === "Hello"); + expect(newCard?.lastReview).toBeNull(); + }); + + it("should strip HTML tags from fields", () => { + // Create a mock package with HTML in fields + const htmlPkg: AnkiPackage = { + notes: [ + { + id: 1, + guid: "test1", + mid: 1, + mod: 0, + tags: [], + fields: ["<b>Bold</b> text", "<div>Answer</div><br/>Line 2"], + sfld: "Bold text", + }, + ], + cards: [ + { + id: 1, + nid: 1, + did: 2, + ord: 0, + mod: 0, + type: 0, + queue: 0, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + }, + ], + decks: [{ id: 2, name: "HTML Test", description: "" }], + models: [ + { + id: 1, + name: "Basic", + fields: ["Front", "Back"], + templates: [ + { name: "Card 1", qfmt: "{{Front}}", afmt: "{{Back}}" }, + ], + }, + ], + }; + + const result = mapAnkiToKioku(htmlPkg); + const card = result[0]?.cards[0]; + + expect(card?.front).toBe("Bold text"); + expect(card?.back).toBe("Answer\nLine 2"); + }); + + it("should decode HTML entities", () => { + const htmlPkg: AnkiPackage = { + notes: [ + { + id: 1, + guid: "test1", + mid: 1, + mod: 0, + tags: [], + fields: [ + "<code> & "quotes"", + " spaced ", + ], + sfld: "code", + }, + ], + cards: [ + { + id: 1, + nid: 1, + did: 2, + ord: 0, + mod: 0, + type: 0, + queue: 0, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + }, + ], + decks: [{ id: 2, name: "Entity Test", description: "" }], + models: [ + { + id: 1, + name: "Basic", + fields: ["Front", "Back"], + templates: [ + { name: "Card 1", qfmt: "{{Front}}", afmt: "{{Back}}" }, + ], + }, + ], + }; + + const result = mapAnkiToKioku(htmlPkg); + const card = result[0]?.cards[0]; + + expect(card?.front).toBe('<code> & "quotes"'); + expect(card?.back).toBe("spaced"); + }); + + it("should handle empty package", () => { + const emptyPkg: AnkiPackage = { + notes: [], + cards: [], + decks: [], + models: [], + }; + + const result = mapAnkiToKioku(emptyPkg); + expect(result).toEqual([]); + }); + + it("should skip cards with missing notes", () => { + const incompletePkg: AnkiPackage = { + notes: [], // No notes + cards: [ + { + id: 1, + nid: 999, // Non-existent note + did: 2, + ord: 0, + mod: 0, + type: 0, + queue: 0, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + }, + ], + decks: [{ id: 2, name: "Test", description: "" }], + models: [], + }; + + const result = mapAnkiToKioku(incompletePkg); + // Should have deck but no cards + expect(result).toEqual([]); + }); + }); }); 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(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/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; +} |
