summaryrefslogtreecommitdiff
path: root/ext/js/comm/anki-connect.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/comm/anki-connect.js')
-rw-r--r--ext/js/comm/anki-connect.js312
1 files changed, 275 insertions, 37 deletions
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js
index 09838ea5..7ff8d0e1 100644
--- a/ext/js/comm/anki-connect.js
+++ b/ext/js/comm/anki-connect.js
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import {isObject} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
import {AnkiUtil} from '../data/anki-util.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;
+ }
}