import axios, { AxiosInstance } from 'axios';
|
|
|
|
import { IAnkiCard, IAnkiDeckConfig, IAnkiQuery } from './anki';
|
|
import { logger } from './logger';
|
|
|
|
export interface IAnkiConnectActions
|
|
extends Record<
|
|
string,
|
|
{
|
|
params?: Record<string, unknown>;
|
|
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<A extends keyof IAnkiConnectActions>(
|
|
action: A,
|
|
params: IAnkiConnectActions[A]['params'],
|
|
version = this.version,
|
|
): Promise<IAnkiConnectActions[A]['result']> {
|
|
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<Actions extends (keyof IAnkiConnectActions)[]>(
|
|
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;
|
|
}
|
|
}
|