import axios, { type AxiosInstance } from "axios";
import type {
AnkiConnectRequest,
AnkiConnectResponse,
AnkiNote,
AnkiCard,
AnkiNoteInfo,
AnkiDeckConfig,
AnkiMultiAction,
AnkiConnectorConfig,
AnkiApiError,
AnkiUpdateCardNotesRequest,
} from "./ankiConnectorTypes";
export const ANKI_CONNECT_URL = "http://localhost:8765";
export const ANKI_CONNECT_VERSION = 6;
export class AnkiConnector {
private _client: AxiosInstance;
private _url: string;
private _config: AnkiConnectorConfig;
constructor(config: Partial<AnkiConnectorConfig> = {}) {
this._config = {
url: config.url || ANKI_CONNECT_URL,
timeout: config.timeout || 10000,
retries: config.retries || 3,
retryDelay: config.retryDelay || 1000,
};
this._url = this._config.url;
this._client = axios.create({
baseURL: this._url,
timeout: this._config.timeout,
headers: {
"Content-Type": "application/json",
Connection: "keep-alive",
"Keep-Alive": "timeout=5, max=1000",
},
});
}
/**
* Perform an arbitrary request via anki-connect
*/
async request<T = any>(
action: string,
params?: any,
retryCount = 0,
): Promise<T> {
const request: AnkiConnectRequest = {
action,
version: ANKI_CONNECT_VERSION,
params,
};
try {
const response = await this._client.post<AnkiConnectResponse<T>>(
"",
request,
);
if (response.data.error) {
const error: AnkiApiError = {
message: response.data.error,
code: "ANKI_CONNECT_ERROR",
details: { action, params },
};
throw error;
}
return response.data.result;
} catch (error) {
if (retryCount < this._config.retries! && this._shouldRetry(error)) {
await this._delay(this._config.retryDelay! * (retryCount + 1));
return this.request(action, params, retryCount + 1);
}
if (axios.isAxiosError(error)) {
const apiError: AnkiApiError = {
message: `Network Error: ${error.message}`,
code: error.code,
details: { action, params, originalError: error },
};
throw apiError;
}
throw error;
}
}
private _shouldRetry(error: any): boolean {
if (axios.isAxiosError(error)) {
return (
error.code === "ECONNREFUSED" ||
error.code === "ECONNRESET" ||
error.code === "ETIMEDOUT" ||
(error.response != null && error.response.status >= 500)
);
}
return false;
}
private _delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Multi operations
async multi(actions: AnkiMultiAction[]): Promise<any[]> {
return this.request("multi", { actions });
}
// Version and upgrade
async version(): Promise<number> {
return this.request("version");
}
async upgrade(): Promise<boolean> {
return this.request("upgrade");
}
// Media operations
async storeMediaFile(filename: string, data: string): Promise<void> {
return this.request("storeMediaFile", { filename, data });
}
async retrieveMediaFile(filename: string): Promise<string | false> {
return this.request("retrieveMediaFile", { filename });
}
async deleteMediaFile(filename: string): Promise<void> {
return this.request("deleteMediaFile", { filename });
}
// Deck operations
async deckNames(): Promise<string[]> {
return this.request("deckNames");
}
async deckNamesAndIds(): Promise<Record<string, number>> {
return this.request("deckNamesAndIds");
}
async getDeckConfig(deck: string): Promise<AnkiDeckConfig | false> {
return this.request("getDeckConfig", { deck });
}
async saveDeckConfig(config: AnkiDeckConfig): Promise<boolean> {
return this.request("saveDeckConfig", { config });
}
async setDeckConfigId(decks: string[], configId: number): Promise<boolean> {
return this.request("setDeckConfigId", { decks, configId });
}
async cloneDeckConfigId(
name: string,
cloneFrom: number = 1,
): Promise<number> {
return this.request("cloneDeckConfigId", { name, cloneFrom });
}
async removeDeckConfigId(configId: number): Promise<boolean> {
return this.request("removeDeckConfigId", { configId });
}
async deleteDecks(decks: string[], cardsToo: boolean = false): Promise<void> {
return this.request("deleteDecks", { decks, cardsToo });
}
async changeDeck(cards: number[], deck: string): Promise<void> {
return this.request("changeDeck", { cards, deck });
}
async getDecks(cards: number[]): Promise<Record<string, number[]>> {
return this.request("getDecks", { cards });
}
// Model operations
async modelNames(): Promise<string[]> {
return this.request("modelNames");
}
async modelNamesAndIds(): Promise<Record<string, number>> {
return this.request("modelNamesAndIds");
}
async modelFieldNames(modelName: string): Promise<string[]> {
return this.request("modelFieldNames", { modelName });
}
async modelFieldsOnTemplates(
modelName: string,
): Promise<Record<string, string[][]>> {
return this.request("modelFieldsOnTemplates", { modelName });
}
// Note operations
async addNote(note: AnkiNote): Promise<number | null> {
return this.request("addNote", { note });
}
async addNotes(notes: AnkiNote[]): Promise<(number | null)[]> {
return this.request("addNotes", { notes });
}
async updateNoteFields(request: AnkiUpdateCardNotesRequest): Promise<void> {
return this.request("updateNoteFields", request);
}
async canAddNotes(notes: AnkiNote[]): Promise<boolean[]> {
return this.request("canAddNotes", { notes });
}
async findNotes(query?: string): Promise<number[]> {
return this.request("findNotes", { query });
}
async notesInfo(notes: number[]): Promise<AnkiNoteInfo[]> {
return this.request("notesInfo", { notes });
}
async cardsToNotes(cards: number[]): Promise<number[]> {
return this.request("cardsToNotes", { cards });
}
// Card operations
async findCards(query?: string): Promise<number[]> {
return this.request("findCards", { query });
}
async cardsInfo(cards: number[]): Promise<AnkiCard[]> {
return this.request("cardsInfo", { cards });
}
async suspend(cards: number[], suspend: boolean = true): Promise<boolean> {
return this.request("suspend", { cards, suspend });
}
async unsuspend(cards: number[]): Promise<boolean> {
return this.request("unsuspend", { cards });
}
async areSuspended(cards: number[]): Promise<boolean[]> {
return this.request("areSuspended", { cards });
}
async areDue(cards: number[]): Promise<boolean[]> {
return this.request("areDue", { cards });
}
async getIntervals(
cards: number[],
complete: boolean = false,
): Promise<number[]> {
return this.request("getIntervals", { cards, complete });
}
// Tag operations
async addTags(
notes: number[],
tags: string[],
add: boolean = true,
): Promise<void> {
return this.request("addTags", { notes, tags, add });
}
async removeTags(notes: number[], tags: string[]): Promise<void> {
return this.request("removeTags", { notes, tags });
}
async getTags(): Promise<string[]> {
return this.request("getTags");
}
// GUI operations
async guiBrowse(query?: string): Promise<number[]> {
return this.request("guiBrowse", { query });
}
async guiAddCards(): Promise<void> {
return this.request("guiAddCards");
}
async guiCurrentCard(): Promise<AnkiCard | null> {
return this.request("guiCurrentCard");
}
async guiStartCardTimer(): Promise<boolean> {
return this.request("guiStartCardTimer");
}
async guiAnswerCard(ease: number): Promise<boolean> {
return this.request("guiAnswerCard", { ease });
}
async guiShowQuestion(): Promise<boolean> {
return this.request("guiShowQuestion");
}
async guiShowAnswer(): Promise<boolean> {
return this.request("guiShowAnswer");
}
async guiDeckOverview(name: string): Promise<boolean> {
return this.request("guiDeckOverview", { name });
}
async guiDeckBrowser(): Promise<void> {
return this.request("guiDeckBrowser");
}
async guiDeckReview(name: string): Promise<boolean> {
return this.request("guiDeckReview", { name });
}
async guiExitAnki(): Promise<void> {
return this.request("guiExitAnki");
}
getUrl(): string {
return this._url;
}
async testConnection(): Promise<boolean> {
try {
await this.version();
return true;
} catch {
return false;
}
}
async ping(): Promise<{
success: boolean;
latency?: number;
error?: string;
}> {
const start = Date.now();
try {
await this.version();
const latency = Date.now() - start;
return { success: true, latency };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
async createBasicNote(
deckName: string,
front: string,
back: string,
tags: string[] = [],
): Promise<number | null> {
const note: AnkiNote = {
deckName,
modelName: "Basic",
fields: {
Front: front,
Back: back,
},
tags,
};
return this.addNote(note);
}
async searchCards(query: string): Promise<AnkiCard[]> {
const cardIds = await this.findCards(query);
if (cardIds.length === 0) return [];
return this.cardsInfo(cardIds);
}
async searchNotes(query: string): Promise<AnkiNoteInfo[]> {
const noteIds = await this.findNotes(query);
if (noteIds.length === 0) return [];
return this.notesInfo(noteIds);
}
async getDeckStats(): Promise<
Record<
string,
{ total: number; new: number; learning: number; review: number }
>
> {
const deckNames = await this.deckNames();
const stats: Record<string, any> = {};
for (const deckName of deckNames) {
const allCards = await this.findCards(`deck:"${deckName}"`);
const newCards = await this.findCards(`deck:"${deckName}" is:new`);
const learningCards = await this.findCards(`deck:"${deckName}" is:learn`);
const reviewCards = await this.findCards(`deck:"${deckName}" is:review`);
stats[deckName] = {
total: allCards.length,
new: newCards.length,
learning: learningCards.length,
review: reviewCards.length,
};
}
return stats;
}
async bulkAddNotes(
notes: AnkiNote[],
batchSize: number = 100,
): Promise<(number | null)[]> {
const results: (number | null)[] = [];
for (let i = 0; i < notes.length; i += batchSize) {
const batch = notes.slice(i, i + batchSize);
const batchResults = await this.addNotes(batch);
results.push(...batchResults);
}
return results;
}
}
export const ankiConnector = new AnkiConnector();