diff options
Diffstat (limited to 'ext/js/comm/anki-connect.js')
-rw-r--r-- | ext/js/comm/anki-connect.js | 310 |
1 files changed, 274 insertions, 36 deletions
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 09838ea5..3262af41 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -27,17 +27,23 @@ export class AnkiConnect { * Creates a new instance. */ constructor() { + /** @type {boolean} */ this._enabled = false; + /** @type {?string} */ this._server = null; + /** @type {number} */ this._localVersion = 2; + /** @type {number} */ this._remoteVersion = 0; + /** @type {?Promise<number>} */ this._versionCheckPromise = null; + /** @type {?string} */ this._apiKey = null; } /** * Gets the URL of the AnkiConnect server. - * @type {string} + * @type {?string} */ get server() { return this._server; @@ -90,7 +96,7 @@ export class AnkiConnect { */ async isConnected() { try { - await this._invoke('version'); + await this._getVersion(); return true; } catch (e) { return false; @@ -99,74 +105,114 @@ export class AnkiConnect { /** * Gets the AnkiConnect API version number. - * @returns {Promise<number>} The version number + * @returns {Promise<?number>} The version number */ async getVersion() { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('version', {}); + return await this._getVersion(); } + /** + * @param {import('anki').Note} note + * @returns {Promise<?import('anki').NoteId>} + */ async addNote(note) { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('addNote', {note}); + const result = await this._invoke('addNote', {note}); + if (result !== null && typeof result !== 'number') { + throw this._createUnexpectedResultError('number|null', result); + } + return result; } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<boolean[]>} + */ async canAddNotes(notes) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('canAddNotes', {notes}); + const result = await this._invoke('canAddNotes', {notes}); + return this._normalizeArray(result, notes.length, 'boolean'); } - async notesInfo(notes) { + /** + * @param {import('anki').NoteId[]} noteIds + * @returns {Promise<(?import('anki').NoteInfo)[]>} + */ + async notesInfo(noteIds) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('notesInfo', {notes}); + const result = await this._invoke('notesInfo', {notes: noteIds}); + return this._normalizeNoteInfoArray(result); } + /** + * @returns {Promise<string[]>} + */ async getDeckNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('deckNames'); + const result = await this._invoke('deckNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @returns {Promise<string[]>} + */ async getModelNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelNames'); + const result = await this._invoke('modelNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} modelName + * @returns {Promise<string[]>} + */ async getModelFieldNames(modelName) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelFieldNames', {modelName}); + const result = await this._invoke('modelFieldNames', {modelName}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowse(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('guiBrowse', {query}); + const result = await this._invoke('guiBrowse', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowseNote(noteId) { return await this.guiBrowse(`nid:${noteId}`); } /** * Opens the note editor GUI. - * @param {number} noteId The ID of the note. - * @returns {Promise<null>} Nothing is returned. + * @param {import('anki').NoteId} noteId The ID of the note. + * @returns {Promise<void>} Nothing is returned. */ async guiEditNote(noteId) { - return await this._invoke('guiEditNote', {note: noteId}); + await this._invoke('guiEditNote', {note: noteId}); } /** * Stores a file with the specified base64-encoded content inside Anki's media folder. * @param {string} fileName The name of the file. * @param {string} content The base64-encoded content of the file. - * @returns {?string} The actual file name used to store the file, which may be different; or `null` if the file was not stored. + * @returns {Promise<?string>} The actual file name used to store the file, which may be different; or `null` if the file was not stored. * @throws {Error} An error is thrown is this object is not enabled. */ async storeMediaFile(fileName, content) { @@ -174,28 +220,39 @@ export class AnkiConnect { throw new Error('AnkiConnect not enabled'); } await this._checkVersion(); - return await this._invoke('storeMediaFile', {filename: fileName, data: content}); + const result = await this._invoke('storeMediaFile', {filename: fileName, data: content}); + if (result !== null && typeof result !== 'string') { + throw this._createUnexpectedResultError('string|null', result); + } + return result; } /** * Finds notes matching a query. * @param {string} query Searches for notes matching a query. - * @returns {number[]} An array of note IDs. + * @returns {Promise<import('anki').NoteId[]>} An array of note IDs. * @see https://docs.ankiweb.net/searching.html */ async findNotes(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findNotes', {query}); + const result = await this._invoke('findNotes', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<import('anki').NoteId[][]>} + */ async findNoteIds(notes) { if (!this._enabled) { return []; } await this._checkVersion(); const actions = []; const actionsTargetsList = []; + /** @type {Map<string, import('anki').NoteId[][]>} */ const actionsTargetsMap = new Map(); + /** @type {import('anki').NoteId[][]} */ const allNoteIds = []; for (const note of notes) { @@ -207,14 +264,15 @@ export class AnkiConnect { actionsTargetsMap.set(query, actionsTargets); actions.push({action: 'findNotes', params: {query}}); } + /** @type {import('anki').NoteId[]} */ const noteIds = []; allNoteIds.push(noteIds); actionsTargets.push(noteIds); } - const result = await this._invoke('multi', {actions}); + const result = await this._invokeMulti(actions); for (let i = 0, ii = Math.min(result.length, actionsTargetsList.length); i < ii; ++i) { - const noteIds = result[i]; + const noteIds = /** @type {number[]} */ (this._normalizeArray(result[i], -1, 'number')); for (const actionsTargets of actionsTargetsList[i]) { for (const noteId of noteIds) { actionsTargets.push(noteId); @@ -224,18 +282,32 @@ export class AnkiConnect { return allNoteIds; } + /** + * @param {import('anki').CardId[]} cardIds + * @returns {Promise<boolean>} + */ async suspendCards(cardIds) { if (!this._enabled) { return false; } await this._checkVersion(); - return await this._invoke('suspend', {cards: cardIds}); + const result = await this._invoke('suspend', {cards: cardIds}); + return typeof result === 'boolean' && result; } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async findCards(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findCards', {query}); + const result = await this._invoke('findCards', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async findCardsForNote(noteId) { return await this.findCards(`nid:${noteId}`); } @@ -244,16 +316,26 @@ export class AnkiConnect { * Gets information about the AnkiConnect APIs available. * @param {string[]} scopes A list of scopes to get information about. * @param {?string[]} actions A list of actions to check for - * @returns {object} Information about the APIs. + * @returns {Promise<import('anki').ApiReflectResult>} Information about the APIs. */ async apiReflect(scopes, actions=null) { - return await this._invoke('apiReflect', {scopes, actions}); + const result = await this._invoke('apiReflect', {scopes, actions}); + if (!(typeof result === 'object' && result !== null)) { + throw this._createUnexpectedResultError('object', result); + } + const {scopes: resultScopes, actions: resultActions} = /** @type {import('core').SerializableObject} */ (result); + const resultScopes2 = /** @type {string[]} */ (this._normalizeArray(resultScopes, -1, 'string', ', field scopes')); + const resultActions2 = /** @type {string[]} */ (this._normalizeArray(resultActions, -1, 'string', ', field scopes')); + return { + scopes: resultScopes2, + actions: resultActions2 + }; } /** * Checks whether a specific API action exists. * @param {string} action The action to check for. - * @returns {boolean} Whether or not the action exists. + * @returns {Promise<boolean>} Whether or not the action exists. */ async apiExists(action) { const {actions} = await this.apiReflect(['actions'], [action]); @@ -266,9 +348,9 @@ export class AnkiConnect { * @returns {boolean} Whether or not the error indicates the action is not supported. */ isErrorUnsupportedAction(error) { - if (error instanceof Error) { + if (error instanceof ExtensionError) { const {data} = error; - if (isObject(data) && data.apiError === 'unsupported action') { + if (typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).apiError === 'unsupported action') { return true; } } @@ -277,10 +359,13 @@ export class AnkiConnect { // Private + /** + * @returns {Promise<void>} + */ async _checkVersion() { if (this._remoteVersion < this._localVersion) { if (this._versionCheckPromise === null) { - const promise = this._invoke('version'); + const promise = this._getVersion(); promise .catch(() => {}) .finally(() => { this._versionCheckPromise = null; }); @@ -293,11 +378,18 @@ export class AnkiConnect { } } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<unknown>} + */ async _invoke(action, params) { + /** @type {import('anki').MessageBody} */ const body = {action, params, version: this._localVersion}; if (this._apiKey !== null) { body.key = this._apiKey; } let response; try { + if (this._server === null) { throw new Error('Server URL is null'); } response = await fetch(this._server, { method: 'POST', mode: 'cors', @@ -311,33 +403,34 @@ export class AnkiConnect { body: JSON.stringify(body) }); } catch (e) { - const error = new Error('Anki connection failure'); + const error = new ExtensionError('Anki connection failure'); error.data = {action, params, originalError: e}; throw error; } if (!response.ok) { - const error = new Error(`Anki connection error: ${response.status}`); + const error = new ExtensionError(`Anki connection error: ${response.status}`); error.data = {action, params, status: response.status}; throw error; } let responseText = null; + /** @type {unknown} */ let result; try { responseText = await response.text(); result = JSON.parse(responseText); } catch (e) { - const error = new Error('Invalid Anki response'); + const error = new ExtensionError('Invalid Anki response'); error.data = {action, params, status: response.status, responseText, originalError: e}; throw error; } - if (isObject(result)) { - const apiError = result.error; + if (typeof result === 'object' && result !== null && !Array.isArray(result)) { + const apiError = /** @type {import('core').SerializableObject} */ (result).error; if (typeof apiError !== 'undefined') { - const error = new Error(`Anki error: ${apiError}`); - error.data = {action, params, status: response.status, apiError}; + const error = new ExtensionError(`Anki error: ${apiError}`); + error.data = {action, params, status: response.status, apiError: typeof apiError === 'string' ? apiError : `${apiError}`}; throw error; } } @@ -345,10 +438,30 @@ export class AnkiConnect { return result; } + /** + * @param {{action: string, params: import('core').SerializableObject}[]} actions + * @returns {Promise<unknown[]>} + */ + async _invokeMulti(actions) { + const result = await this._invoke('multi', {actions}); + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result); + } + return result; + } + + /** + * @param {string} text + * @returns {string} + */ _escapeQuery(text) { return text.replace(/"/g, ''); } + /** + * @param {import('anki').NoteFields} fields + * @returns {string} + */ _fieldsToQuery(fields) { const fieldNames = Object.keys(fields); if (fieldNames.length === 0) { @@ -359,6 +472,10 @@ export class AnkiConnect { return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`; } + /** + * @param {import('anki').Note} note + * @returns {?('collection'|'deck'|'deck-root')} + */ _getDuplicateScopeFromNote(note) { const {options} = note; if (typeof options === 'object' && options !== null) { @@ -370,6 +487,10 @@ export class AnkiConnect { return null; } + /** + * @param {import('anki').Note} note + * @returns {string} + */ _getNoteQuery(note) { let query = ''; switch (this._getDuplicateScopeFromNote(note)) { @@ -383,4 +504,121 @@ export class AnkiConnect { query += this._fieldsToQuery(note.fields); return query; } + + /** + * @returns {Promise<number>} + */ + async _getVersion() { + const version = await this._invoke('version', {}); + return typeof version === 'number' ? version : 0; + } + + /** + * @param {string} message + * @param {unknown} data + * @returns {ExtensionError} + */ + _createError(message, data) { + const error = new ExtensionError(message); + error.data = data; + return error; + } + + /** + * @param {string} expectedType + * @param {unknown} result + * @param {string} [context] + * @returns {ExtensionError} + */ + _createUnexpectedResultError(expectedType, result, context) { + return this._createError(`Unexpected type${typeof context === 'string' ? context : ''}: expected ${expectedType}, received ${this._getTypeName(result)}`, result); + } + + /** + * @param {unknown} value + * @returns {string} + */ + _getTypeName(value) { + if (value === null) { return 'null'; } + return Array.isArray(value) ? 'array' : typeof value; + } + + /** + * @template [T=unknown] + * @param {unknown} result + * @param {number} expectedCount + * @param {'boolean'|'string'|'number'} type + * @param {string} [context] + * @returns {T[]} + * @throws {Error} + */ + _normalizeArray(result, expectedCount, type, context) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError(`${type}[]`, result, context); + } + if (expectedCount < 0) { + expectedCount = result.length; + } else if (expectedCount !== result.length) { + throw this._createError(`Unexpected result array size${context}: expected ${expectedCount}, received ${result.length}`, result); + } + for (let i = 0; i < expectedCount; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (typeof item !== type) { + throw this._createError(`Unexpected result type at index ${i}${context}: expected ${type}, received ${this._getTypeName(item)}`, result); + } + } + return /** @type {T[]} */ (result); + } + + /** + * @param {unknown} result + * @returns {(?import('anki').NoteInfo)[]} + * @throws {Error} + */ + _normalizeNoteInfoArray(result) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result, ''); + } + /** @type {(?import('anki').NoteInfo)[]} */ + const result2 = []; + for (let i = 0, ii = result.length; i < ii; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (item === null || typeof item !== 'object') { + throw this._createError(`Unexpected result type at index ${i}: expected Notes.NoteInfo, received ${this._getTypeName(item)}`, result); + } + const {noteId} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof noteId !== 'number') { + result2.push(null); + continue; + } + + const {tags, fields, modelName, cards} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof modelName !== 'string') { + throw this._createError(`Unexpected result type at index ${i}, field modelName: expected string, received ${this._getTypeName(modelName)}`, result); + } + if (typeof fields !== 'object' || fields === null) { + throw this._createError(`Unexpected result type at index ${i}, field fields: expected string, received ${this._getTypeName(fields)}`, result); + } + const tags2 = /** @type {string[]} */ (this._normalizeArray(tags, -1, 'string', ', field tags')); + const cards2 = /** @type {number[]} */ (this._normalizeArray(cards, -1, 'number', ', field cards')); + /** @type {{[key: string]: import('anki').NoteFieldInfo}} */ + const fields2 = {}; + for (const [key, fieldInfo] of Object.entries(fields)) { + if (typeof fieldInfo !== 'object' || fieldInfo === null) { continue; } + const {value, order} = fieldInfo; + if (typeof value !== 'string' || typeof order !== 'number') { continue; } + fields2[key] = {value, order}; + } + /** @type {import('anki').NoteInfo} */ + const item2 = { + noteId, + tags: tags2, + fields: fields2, + modelName, + cards: cards2 + }; + result2.push(item2); + } + return result2; + } } |