aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-15 22:34:33 +0900
committernsfisis <nsfisis@gmail.com>2025-12-15 22:34:33 +0900
commitadc30217b6fa5773f9fb96c6fb106102cd865a89 (patch)
tree7c105c9c77b1bb85112a108f55d381b29f18497f
parentced08d592e3d277044eb9bbfea1bef0e4e4285e3 (diff)
downloadkioku-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>
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/server/anki/index.ts4
-rw-r--r--src/server/anki/parser.test.ts342
-rw-r--r--src/server/anki/parser.ts268
4 files changed, 615 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index d326ea5..d7d49ac 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -178,7 +178,7 @@ Smaller features first to enable early MVP validation.
### Parser
- [x] ZIP extraction
- [x] SQLite database reading
-- [ ] Note/Card mapping to Kioku format
+- [x] Note/Card mapping to Kioku format
- [x] Add tests
### Server command
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: [
+ "&lt;code&gt; &amp; &quot;quotes&quot;",
+ "&nbsp;spaced&nbsp;",
+ ],
+ 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(/&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;
+}