Browse Source

strongly typed AnkiConnect

main
parent
commit
a144c7fd74
3 changed files with 265 additions and 57 deletions
  1. +27
    -0
      scripts/sort-anki.ts
  2. +7
    -0
      src/anki.ts
  3. +231
    -57
      src/ankiconnect.ts

+ 27
- 0
scripts/sort-anki.ts View File

@ -0,0 +1,27 @@
import { AnkiConnect } from '@/ankiconnect';
async function main() {
const anki = new AnkiConnect();
console.log(
await anki.multi<['findNotes', 'getTags']>({
actions: [
{
action: 'findNotes',
params: {
query: 'added:1',
},
},
{ action: 'getTags' },
],
}),
);
console.log(
await anki.api('findNotes', {
query: 'deck:Takoboto',
}),
);
}
if (require.main === module) {
main();
}

+ 7
- 0
src/anki.ts View File

@ -1,3 +1,10 @@
/**
* Query syntax is documented here.
*
* @link https://docs.ankiweb.net/searching.html
*/
export type IAnkiQuery = string;
export interface IAnkiCard {
sortf: number;
did: number;

+ 231
- 57
src/ankiconnect.ts View File

@ -1,11 +1,6 @@
import axios, { AxiosInstance } from 'axios';
import { IAnkiCard } from './anki';
export interface IAnkiConnectResponse<K extends keyof IAnkiConnectActions> {
result: IAnkiConnectActions[K];
error: string | null;
}
import { IAnkiCard, IAnkiQuery } from './anki';
export interface IAnkiConnectActions
extends Record<
@ -37,31 +32,7 @@ export interface IAnkiConnectActions
// Media Actions
storeMediaFile: {
params: (
| {
/**
* 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;
};
params: IAnkiConnectMedia;
result: string;
};
retrieveMediaFile: {
@ -222,8 +193,112 @@ export interface IAnkiConnectActions
// Note Actions
addNote: {
params: {};
result: number;
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;
};
}
@ -236,6 +311,86 @@ export interface IAnkiConnectCardTemplate {
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;
@ -248,31 +403,8 @@ export class AnkiConnect {
async api<A extends keyof IAnkiConnectActions>(
action: A,
params: IAnkiConnectActions[A]['params'],
version?: number,
): Promise<IAnkiConnectActions[A]['result']>;
async api<Results extends IAnkiConnectResponse<any>[]>(
action: 'multi',
params: {
actions: Array<
{
[A in keyof IAnkiConnectActions]: {
action: A;
params: IAnkiConnectActions[A]['params'];
};
}[keyof IAnkiConnectActions]
>;
},
version?: number,
): Promise<{
result: Results;
}>;
async api<A extends keyof IAnkiConnectActions | 'multi'>(
action: A,
params: unknown,
version = this.version,
) {
): Promise<IAnkiConnectActions[A]['result']> {
const { data: response } = await this.$axios.post<{
result: any;
error: string | null;
@ -293,4 +425,46 @@ export class AnkiConnect {
return response.result;
}
/**
* Strongly-typed usage is via `[string, tuple]`:
*
* ```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;
}
}

Loading…
Cancel
Save