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/server/anki/parser.ts | |
| 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/server/anki/parser.ts')
| -rw-r--r-- | src/server/anki/parser.ts | 268 |
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(/ /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; +} |
