import axios, { AxiosInstance } from 'axios'; import { IAnkiCard, IAnkiDeckConfig, IAnkiQuery } from './anki'; import { logger } from './logger'; export interface IAnkiConnectActions extends Record< string, { params?: Record; result: unknown; } > { // Card Actions cardsInfo: { params: { cards: number[]; }; result: { answer: string; question: string; deckName: string; modelName: string; fieldOrder: number; fields: { [fieldName: string]: { value: string; order: number; }; }; css: string; cardId: number; interval: number; note: number; ord: number; type: number; queue: number; due: number; reps: number; lapses: number; left: number; mod: number; }[]; }; findCards: { params: { query: IAnkiQuery; }; result: number[]; }; // Deck Actions deckNames: { result: string[]; }; getDecks: { params: { cards: number[]; }; result: { [deckName: string]: number[]; }; }; changeDeck: { params: { cards: number[]; deck: string; }; result: null; }; getDeckConfig: { params: { deck: string; }; result: IAnkiDeckConfig; }; setDeckConfigId: { params: { decks: string[]; configId: number; }; result: boolean; }; // Media Actions storeMediaFile: { params: IAnkiConnectMedia; result: string; }; retrieveMediaFile: { /** * Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist. */ params: { filename: string; }; result: string | false; }; getMediaFilesNames: { /** * Gets the names of media files matched the pattern. Returning all names by default. */ params: { pattern: string; }; result: string[]; }; deleteMediaFile: { params: { filename: string; }; result: null; }; // Miscellaneous Actions exportPackage: { /** * Exports a given deck in .apkg format. * Returns true if successful or false otherwise. */ params: { deck: string; path: string; /** * The optional property includeSched (default is false) can be specified to include the cards’ scheduling data. */ includeSched?: boolean; }; result: boolean; }; importPackage: { /** * Imports a file in .apkg format into the collection. * Returns true if successful or false otherwise. */ params: { /** * Note that the file path is relative to Anki’s collection.media folder, not to the client. */ path: string; }; result: boolean; }; reloadCollection: { result: null; }; // Model Actions modelNames: { result: string[]; }; modelNamesAndIds: { result: { [modelName: string]: number; }; }; modelFieldNames: { params: { modelName: string; }; result: string[]; }; modelFieldsOnTemplates: { params: { modelName: string; }; result: { [side: string]: [string[], string[]]; }; }; createModel: { params: { modelName: string; inOrderFields: string[]; /** * Default to built-in CSS */ css?: string; isCloze?: boolean; cardTemplates: IAnkiConnectCardTemplate[]; }; result: IAnkiCard; }; modelTemplates: { params: { modelName: string; }; result: { [side: string]: { Front: string; Back: string; }; }; }; modelStyling: { params: { modelName: string; }; result: { css: string; }; }; updateModelTemplates: { /** * Modify the templates of an existing model by name. * Only specifies cards and specified sides will be modified. * * If an existing card or side is not included in the request, it will be left unchanged. */ params: { model: { name: string; templates: { [side: string]: { Front?: string; Back?: string; }; }; }; }; result: null; }; updateModelStyling: { params: { model: { name: string; css: string; }; }; result: null; }; findAndReplaceInModels: { params: { model: { modelName: string; findText: string; replaceText: string; front?: boolean; back?: boolean; css?: boolean; }; }; result: number; }; // Note Actions addNote: { params: { note: IAnkiConnectNote; }; result: number | null; }; addNotes: { params: { notes: IAnkiConnectNote[]; }; result: (number | null)[]; }; canAddNotes: { params: { notes: IAnkiConnectNote[]; }; result: boolean[]; }; updateNoteFields: { params: { note: { id: number; fields?: { [fieldName: string]: string; }; audio?: IAnkiConnectMediaInNote[]; video?: IAnkiConnectMediaInNote[]; picture?: IAnkiConnectMediaInNote[]; }; }; result: null; }; addTags: { params: { notes: number[]; /** * The docs don't make it clear if tags are space-separated. * * @link https://foosoft.net/projects/anki-connect/#miscellaneous-actions */ tags: string; }; result: null; }; removeTags: { params: { notes: number[]; /** * The docs don't make it clear if tags are space-separated. * * @link https://foosoft.net/projects/anki-connect/#miscellaneous-actions */ tags: string; }; result: null; }; getTags: { result: string[]; }; cleanUnusedTags: { result: null; }; replaceTags: { params: { notes: number[]; tag_to_replace: string; replace_with_tag: string; }; result: null; }; replaceTagsInAllNotes: { params: { tag_to_replace: string; replace_with_tag: string; }; result: null; }; findNotes: { params: { query: IAnkiQuery; }; result: number[]; }; notesInfo: { params: { notes: number[]; }; result: { noteId: number; modelName: string; tags: string[]; fields: { [fieldName: string]: { value: string; ord: number; }; }; }[]; }; deleteNotes: { params: { notes: number[]; }; result: null; }; removeEmptyNotes: { result: null; }; } export interface IAnkiConnectCardTemplate { /** * By default the card names will be Card 1, Card 2, and so on. */ Name?: string; Front: string; Back: string; } export interface IAnkiConnectNote { deckName: string; modelName: string; fields: { [fieldName: string]: string; }; options?: { /** * The allowDuplicate member inside options group can be set to true to enable adding duplicate cards. * Normally duplicate cards can not be added and trigger exception. */ allowDuplicate: boolean; /** * The duplicateScope member inside options can be used to specify the scope for which duplicates are checked. * A value of "deck" will only check for duplicates in the target deck; * any other value will check the entire collection. */ duplicateScope?: string; duplicateScopeOptions?: { /** * Specify which deck to use for checking duplicates in. * If undefined or null, the target deck will be used. */ deckName?: string | null; /** * Change whether or not duplicate cards are checked in child decks. * The default value is false. */ checkChildren?: boolean; /** * Specifies whether duplicate checks are performed across all note types. * The default value is false. */ checkAllModel?: boolean; }; }; tags: string[]; audio?: IAnkiConnectMediaInNote[]; video?: IAnkiConnectMediaInNote[]; picture?: IAnkiConnectMediaInNote[]; } export type IAnkiConnectMedia = ( | { /** * Specified base64-encoded contents */ data: string; } | { path: string; } | { url: string; } ) & { /** * To prevent Anki from removing files not used by any cards (e.g. for configuration files), * prefix the filename with an underscore. */ filename: string; /** * Any existing file with the same name is deleted by default. * Set `deleteExisting` to `false` to prevent that by letting Anki give the new file a non-conflicting name. */ deleteExisting?: boolean; }; export type IAnkiConnectMediaInNote = IAnkiConnectMedia & { /** * The skipHash field can be optionally provided to skip the inclusion of files with an MD5 hash that matches the provided value. */ skipHash?: string; /** * The fields member is a list of fields that should play audio or video, * or show a picture when the card is displayed in Anki. */ fields?: string[]; }; export class AnkiConnect { $axios: AxiosInstance; constructor(public url = 'http://localhost:8765', public version = 6) { this.$axios = axios.create({ baseURL: url, }); } async api( action: A, params: IAnkiConnectActions[A]['params'], version = this.version, ): Promise { logger(`AnkiConnect: calling ${action}`, params); const { data: response } = await this.$axios.post<{ result: any; error: string | null; }>('/', { action, params, version }); if (Object.getOwnPropertyNames(response).length != 2) { throw 'response has an unexpected number of fields'; } if (!response.hasOwnProperty('error')) { throw 'response is missing required error field'; } if (!response.hasOwnProperty('result')) { throw 'response is missing required result field'; } if (response.error) { throw response.error; } logger(`AnkiConnect: finished ${action}`, params); return response.result; } /** * Strongly-typed usage is via `<[action1, action2, ...]>`: * * ```typescript * await new AnkiConnect().multi<['findNotes', 'getTags']>({ * actions: [ * { * action: 'findNotes', * params: { * query: 'added:1', * }, * }, * { action: 'getTags' } * ] * }); * ``` */ async multi( params: { actions: { [K in keyof Actions]: Actions[K] extends keyof IAnkiConnectActions ? { action: Actions[K]; } & ('params' extends keyof IAnkiConnectActions[Actions[K]] ? { params: IAnkiConnectActions[Actions[K]]['params']; } : {}) : never; }; }, version = this.version, ): Promise<{ [K in keyof Actions]: Actions[K] extends keyof IAnkiConnectActions ? // { result: IAnkiConnectActions[Actions[K]]['result']; error: string | null; } // On try-testing - error fields not returned, unlike in the docs. IAnkiConnectActions[Actions[K]]['result'] : never; }> { return this.api('multi', params, version) as any; } }