diff --git a/scripts/sort-anki.ts b/scripts/sort-anki.ts new file mode 100644 index 0000000..73bcdea --- /dev/null +++ b/scripts/sort-anki.ts @@ -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(); +} diff --git a/src/anki.ts b/src/anki.ts index 144a82d..04d0e2c 100644 --- a/src/anki.ts +++ b/src/anki.ts @@ -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; diff --git a/src/ankiconnect.ts b/src/ankiconnect.ts index 11e3e5d..3aa7435 100644 --- a/src/ankiconnect.ts +++ b/src/ankiconnect.ts @@ -1,11 +1,6 @@ import axios, { AxiosInstance } from 'axios'; -import { IAnkiCard } from './anki'; - -export interface IAnkiConnectResponse { - 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( action: A, params: IAnkiConnectActions[A]['params'], - version?: number, - ): Promise; - - async api[]>( - action: 'multi', - params: { - actions: Array< - { - [A in keyof IAnkiConnectActions]: { - action: A; - params: IAnkiConnectActions[A]['params']; - }; - }[keyof IAnkiConnectActions] - >; - }, - version?: number, - ): Promise<{ - result: Results; - }>; - - async api( - action: A, - params: unknown, version = this.version, - ) { + ): Promise { 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( + 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; + } }