From 50c550d1a42f22c8b94c066c528d1c75e5cea225 Mon Sep 17 00:00:00 2001
From: nsfisis
Date: Tue, 30 Dec 2025 23:05:01 +0900
Subject: feat: remove Anki import feature
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the unused Anki import functionality including parser,
tests, and CLI script. Update documentation to reflect removal.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
src/server/anki/parser.ts | 642 ----------------------------------------------
1 file changed, 642 deletions(-)
delete mode 100644 src/server/anki/parser.ts
(limited to 'src/server/anki/parser.ts')
diff --git a/src/server/anki/parser.ts b/src/server/anki/parser.ts
deleted file mode 100644
index 3c961ca..0000000
--- a/src/server/anki/parser.ts
+++ /dev/null
@@ -1,642 +0,0 @@
-import { randomBytes } from "node:crypto";
-import { existsSync } from "node:fs";
-import { mkdir, open, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { DatabaseSync } from "node:sqlite";
-import { createInflateRaw } from "node:zlib";
-
-/**
- * Represents a note from an Anki database
- */
-export interface AnkiNote {
- id: number;
- guid: string;
- mid: number; // model/notetype id
- mod: number;
- tags: string[];
- fields: string[]; // fields separated by 0x1f in the database
- sfld: string; // sort field
-}
-
-/**
- * Represents a card from an Anki database
- */
-export interface AnkiCard {
- id: number;
- nid: number; // note id
- did: number; // deck id
- ord: number; // ordinal (which template/cloze)
- mod: number;
- type: number; // 0=new, 1=learning, 2=review, 3=relearning
- queue: number;
- due: number;
- ivl: number; // interval
- factor: number;
- reps: number;
- lapses: number;
-}
-
-/**
- * Represents a deck from an Anki database
- */
-export interface AnkiDeck {
- id: number;
- name: string;
- description: string;
-}
-
-/**
- * Represents a model (note type) from an Anki database
- */
-export interface AnkiModel {
- id: number;
- name: string;
- fields: string[];
- templates: {
- name: string;
- qfmt: string; // question format
- afmt: string; // answer format
- }[];
-}
-
-/**
- * Represents the parsed contents of an Anki package
- */
-export interface AnkiPackage {
- notes: AnkiNote[];
- cards: AnkiCard[];
- decks: AnkiDeck[];
- models: AnkiModel[];
-}
-
-// Local file header signature
-const LOCAL_FILE_HEADER_SIG = 0x04034b50;
-const CENTRAL_DIR_SIG = 0x02014b50;
-const END_CENTRAL_DIR_SIG = 0x06054b50;
-
-/**
- * Parse a ZIP file and extract entries
- * This is a minimal implementation for .apkg files
- */
-async function parseZip(filePath: string): Promise
, 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();
- for (const note of pkg.notes) {
- noteById.set(note.id, note);
- }
-
- const modelById = new Map();
- for (const model of pkg.models) {
- modelById.set(model.id, model);
- }
-
- const deckById = new Map();
- for (const deck of pkg.decks) {
- deckById.set(deck.id, deck);
- }
-
- // Group cards by deck
- const cardsByDeck = new Map();
- 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;
-}
--
cgit v1.2.3-70-g09d2