diff --git a/.gitignore b/.gitignore index 0d20b64..b9b667b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.pyc +AnkiConnect.zip diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4cc34ba --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: required +language: python +addons: + hosts: + - docker +services: + - docker +python: + - "2.7" +install: + - docker build -f tests/docker/$ANKI_VERSION/Dockerfile -t txgio/anki-connect:$ANKI_VERSION . +script: + - docker run -ti -d --rm -p 8888:8765 -e ANKICONNECT_BIND_ADDRESS=0.0.0.0 txgio/anki-connect:$ANKI_VERSION + - ./tests/scripts/wait-up.sh http://docker:8888 + - python -m unittest discover -s tests -v + +env: + - ANKI_VERSION=2.0.x + - ANKI_VERSION=2.1.x \ No newline at end of file diff --git a/AnkiConnect.py b/AnkiConnect.py index c7f478f..5699e22 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -17,25 +17,30 @@ import anki import aqt +import base64 import hashlib import inspect import json +import os import os.path +import re import select import socket import sys from time import time +from unicodedata import normalize +from operator import itemgetter # # Constants # -API_VERSION = 4 +API_VERSION = 5 TICK_INTERVAL = 25 URL_TIMEOUT = 10 URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' -NET_ADDRESS = '127.0.0.1' +NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') NET_BACKLOG = 5 NET_PORT = 8765 @@ -64,9 +69,13 @@ else: # Helpers # -def webApi(func): - func.webApi = True - return func +def webApi(*versions): + def decorator(func): + method = lambda *args, **kwargs: func(*args, **kwargs) + setattr(method, 'versions', versions) + setattr(method, 'api', True) + return method + return decorator def makeBytes(data): @@ -80,11 +89,11 @@ def makeStr(data): def download(url): try: resp = web.urlopen(url, timeout=URL_TIMEOUT) - except web.URLError: - return None + except web.URLError as e: + raise Exception('A urlError has occurred for url ' + url + '. Error messages was: ' + e.message) if resp.code != 200: - return None + raise Exception('Return code for url request' + url + 'was not 200. Error code: ' + resp.code) return resp.read() @@ -108,7 +117,6 @@ def verifyStringList(strings): return True - # # AjaxRequest # @@ -177,10 +185,10 @@ class AjaxClient: headers = {} for line in parts[0].split(makeBytes('\r\n')): pair = line.split(makeBytes(': ')) - headers[pair[0]] = pair[1] if len(pair) > 1 else None + headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None headerLength = len(parts[0]) + 4 - bodyLength = int(headers.get(makeBytes('Content-Length'), 0)) + bodyLength = int(headers.get(makeBytes('content-length'), 0)) totalLength = headerLength + bodyLength if totalLength > len(data): @@ -199,6 +207,27 @@ class AjaxServer: self.handler = handler self.clients = [] self.sock = None + self.resetHeaders() + + + def setHeader(self, name, value): + self.extraHeaders[name] = value + + + def resetHeaders(self): + self.headers = [ + ['HTTP/1.1 200 OK', None], + ['Content-Type', 'text/json'], + ['Access-Control-Allow-Origin', '*'] + ] + self.extraHeaders = {} + + + def getHeaders(self): + headers = self.headers[:] + for name in self.extraHeaders: + headers.append([name, self.extraHeaders[name]]) + return headers def advance(self): @@ -240,14 +269,12 @@ class AjaxServer: params = json.loads(makeStr(req.body)) body = makeBytes(json.dumps(self.handler(params))) except ValueError: - body = json.dumps(None); + body = makeBytes(json.dumps(None)) resp = bytes() - headers = [ - ['HTTP/1.1 200 OK', None], - ['Content-Type', 'text/json'], - ['Content-Length', str(len(body))] - ] + + self.setHeader('Content-Length', str(len(body))) + headers = self.getHeaders() for key, value in headers: if value is None: @@ -311,19 +338,45 @@ class AnkiNoteParams: ) + def __str__(self): + return 'DeckName: ' + self.deckName + '. ModelName: ' + self.modelName + '. Fields: ' + str(self.fields) + '. Tags: ' + str(self.tags) + '.' + # # AnkiBridge # class AnkiBridge: + def storeMediaFile(self, filename, data): + self.deleteMediaFile(filename) + self.media().writeData(filename, base64.b64decode(data)) + + + def retrieveMediaFile(self, filename): + # based on writeData from anki/media.py + filename = os.path.basename(filename) + filename = normalize('NFC', filename) + filename = self.media().stripIllegal(filename) + + path = os.path.join(self.media().dir(), filename) + if os.path.exists(path): + with open(path, 'rb') as file: + return base64.b64encode(file.read()).decode('ascii') + + return False + + + def deleteMediaFile(self, filename): + self.media().syncDelete(filename) + + def addNote(self, params): collection = self.collection() if collection is None: - return + raise Exception('Collection was not found.') note = self.createNote(params) if note is None: - return + raise Exception('Failed to create note from params: ' + str(params)) if params.audio is not None and len(params.audio.fields) > 0: data = download(params.audio.url) @@ -348,21 +401,24 @@ class AnkiBridge: def canAddNote(self, note): - return bool(self.createNote(note)) + try: + return bool(self.createNote(note)) + except: + return False def createNote(self, params): collection = self.collection() if collection is None: - return + raise Exception('Collection was not found.') model = collection.models.byName(params.modelName) if model is None: - return + raise Exception('Model was not found for model: ' + params.modelName) deck = collection.decks.byName(params.deckName) if deck is None: - return + raise Exception('Deck was not found for deck: ' + params.deckName) note = anki.notes.Note(collection, model) note.model()['did'] = deck['id'] @@ -372,16 +428,40 @@ class AnkiBridge: if name in note: note[name] = value - if not note.dupeOrEmpty(): + # Returns 1 if empty. 2 if duplicate. Otherwise returns False + duplicateOrEmpty = note.dupeOrEmpty() + if duplicateOrEmpty == 1: + raise Exception('Note was empty. Param were: ' + str(params)) + elif duplicateOrEmpty == 2: + raise Exception('Note is duplicate of existing note. Params were: ' + str(params)) + elif duplicateOrEmpty == False: return note + def updateNoteFields(self, params): + collection = self.collection() + if collection is None: + raise Exception('Collection was not found.') + + note = collection.getNote(params['id']) + if note is None: + raise Exception('Failed to get note:{}'.format(params['id'])) + for name, value in params['fields'].items(): + if name in note: + note[name] = value + note.flush() + + def addTags(self, notes, tags, add=True): self.startEditing() self.collection().tags.bulkAdd(notes, tags, add) self.stopEditing() + def getTags(self): + return self.collection().tags.all() + + def suspend(self, cards, suspend=True): for card in cards: isSuspended = self.isSuspended(card) @@ -402,14 +482,18 @@ class AnkiBridge: return False + def isSuspended(self, card): + card = self.collection().getCard(card) + if card.queue == -1: + return True + else: + return False + + def areSuspended(self, cards): suspended = [] for card in cards: - card = self.collection().getCard(card) - if card.queue == -1: - suspended.append(True) - else: - suspended.append(False) + suspended.append(self.isSuspended(card)) return suspended @@ -474,6 +558,13 @@ class AnkiBridge: return self.collection().sched + def multi(self, actions): + response = [] + for item in actions: + response.append(AnkiConnect.handler(ac, item)) + return response + + def media(self): collection = self.collection() if collection is not None: @@ -486,6 +577,18 @@ class AnkiBridge: return collection.models.allNames() + def modelNamesAndIds(self): + models = {} + + modelNames = self.modelNames() + for model in modelNames: + mid = self.collection().models.byName(model)['id'] + mid = int(mid) # sometimes Anki stores the ID as a string + models[model] = mid + + return models + + def modelNameFromId(self, modelId): collection = self.collection() if collection is not None: @@ -502,11 +605,90 @@ class AnkiBridge: return [field['name'] for field in model['flds']] - def multi(self, actions): - response = [] - for item in actions: - response.append(AnkiConnect.handler(ac, item)) - return response + def modelFieldsOnTemplates(self, modelName): + model = self.collection().models.byName(modelName) + + if model is not None: + templates = {} + for template in model['tmpls']: + fields = [] + + for side in ['qfmt', 'afmt']: + fieldsForSide = [] + + # based on _fieldsOnTemplate from aqt/clayout.py + matches = re.findall('{{[^#/}]+?}}', template[side]) + for match in matches: + # remove braces and modifiers + match = re.sub(r'[{}]', '', match) + match = match.split(':')[-1] + + # for the answer side, ignore fields present on the question side + the FrontSide field + if match == 'FrontSide' or side == 'afmt' and match in fields[0]: + continue + fieldsForSide.append(match) + + + fields.append(fieldsForSide) + + templates[template['name']] = fields + + return templates + + + def getDeckConfig(self, deck): + if not deck in self.deckNames(): + return False + + did = self.collection().decks.id(deck) + return self.collection().decks.confForDid(did) + + + def saveDeckConfig(self, config): + configId = str(config['id']) + if not configId in self.collection().decks.dconf: + return False + + mod = anki.utils.intTime() + usn = self.collection().usn() + + config['mod'] = mod + config['usn'] = usn + + self.collection().decks.dconf[configId] = config + self.collection().decks.changed = True + return True + + + def setDeckConfigId(self, decks, configId): + for deck in decks: + if not deck in self.deckNames(): + return False + + if not str(configId) in self.collection().decks.dconf: + return False + + for deck in decks: + did = str(self.collection().decks.id(deck)) + aqt.mw.col.decks.decks[did]['conf'] = configId + + return True + + + def cloneDeckConfigId(self, name, cloneFrom=1): + if not str(cloneFrom) in self.collection().decks.dconf: + return False + + cloneFrom = self.collection().decks.getConf(cloneFrom) + return self.collection().decks.confId(name, cloneFrom) + + + def removeDeckConfigId(self, configId): + if configId == 1 or not str(configId) in self.collection().decks.dconf: + return False + + self.collection().decks.remConf(configId) + return True def deckNames(self): @@ -515,6 +697,17 @@ class AnkiBridge: return collection.decks.allNames() + def deckNamesAndIds(self): + decks = {} + + deckNames = self.deckNames() + for deck in deckNames: + did = self.collection().decks.id(deck) + decks[deck] = did + + return decks + + def deckNameFromId(self, deckId): collection = self.collection() if collection is not None: @@ -537,6 +730,74 @@ class AnkiBridge: return [] + def cardsInfo(self,cards): + result = [] + for cid in cards: + try: + card = self.collection().getCard(cid) + model = card.model() + note = card.note() + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + result.append({ + 'cardId': card.id, + 'fields': fields, + 'fieldOrder': card.ord, + 'question': card._getQA()['q'], + 'answer': card._getQA()['a'], + 'modelName': model['name'], + 'deckName': self.deckNameFromId(card.did), + 'css': model['css'], + 'factor': card.factor, + #This factor is 10 times the ease percentage, + # so an ease of 310% would be reported as 3100 + 'interval': card.ivl, + 'note': card.nid + }) + except TypeError as e: + # Anki will give a TypeError if the card ID does not exist. + # Best behavior is probably to add an 'empty card' to the + # returned result, so that the items of the input and return + # lists correspond. + result.append({}) + + return result + + + def notesInfo(self,notes): + result = [] + for nid in notes: + try: + note = self.collection().getNote(nid) + model = note.model() + + fields = {} + for info in model['flds']: + order = info['ord'] + name = info['name'] + fields[name] = {'value': note.fields[order], 'order': order} + + result.append({ + 'noteId': note.id, + 'tags' : note.tags, + 'fields': fields, + 'modelName': model['name'], + 'cards': self.collection().db.list( + 'select id from cards where nid = ? order by ord', note.id) + }) + except TypeError as e: + # Anki will give a TypeError if the note ID does not exist. + # Best behavior is probably to add an 'empty card' to the + # returned result, so that the items of the input and return + # lists correspond. + result.append({}) + return result + + def getDecks(self, cards): decks = {} for card in cards: @@ -551,6 +812,14 @@ class AnkiBridge: return decks + def createDeck(self, deck): + self.startEditing() + deckId = self.collection().decks.id(deck) + self.stopEditing() + + return deckId + + def changeDeck(self, cards, deck): self.startEditing() @@ -571,8 +840,8 @@ class AnkiBridge: def deleteDecks(self, decks, cardsToo=False): self.startEditing() for deck in decks: - id = self.collection().decks.id(deck) - self.collection().decks.rem(id, cardsToo) + did = self.collection().decks.id(deck) + self.collection().decks.rem(did, cardsToo) self.stopEditing() @@ -605,7 +874,7 @@ class AnkiBridge: def guiCurrentCard(self): if not self.guiReviewActive(): - return + raise Exception('Gui review is not currently active.') reviewer = self.reviewer() card = reviewer.card @@ -625,9 +894,10 @@ class AnkiBridge: 'fieldOrder': card.ord, 'question': card._getQA()['q'], 'answer': card._getQA()['a'], - 'buttons': map(lambda b: b[0], reviewer._answerButtonList()), + 'buttons': [b[0] for b in reviewer._answerButtonList()], 'modelName': model['name'], - 'deckName': self.deckNameFromId(card.did) + 'deckName': self.deckNameFromId(card.did), + 'css': model['css'] } @@ -643,6 +913,7 @@ class AnkiBridge: else: return False + def guiShowQuestion(self): if self.guiReviewActive(): self.reviewer()._showQuestion() @@ -697,10 +968,24 @@ class AnkiBridge: return False + def guiExitAnki(self): + timer = QTimer() + def exitAnki(): + timer.stop() + self.window().close() + timer.timeout.connect(exitAnki) + timer.start(1000) # 1s should be enough to allow the response to be sent. + + + def sync(self): + self.window().onSync() + + # # AnkiConnect # + class AnkiConnect: def __init__(self): self.anki = AnkiBridge() @@ -725,70 +1010,148 @@ class AnkiConnect: def handler(self, request): - action = request.get('action', '') - if hasattr(self, action): - handler = getattr(self, action) - if callable(handler) and hasattr(handler, 'webApi') and getattr(handler, 'webApi'): - spec = inspect.getargspec(handler) - argsAll = spec.args[1:] - argsReq = argsAll + name = request.get('action', '') + version = request.get('version', 4) + params = request.get('params', {}) + reply = {'result': None, 'error': None} - argsDef = spec.defaults - if argsDef is not None: - argsReq = argsAll[:-len(argsDef)] + try: + method = None - params = request.get('params', {}) - for argReq in argsReq: - if argReq not in params: - return - for param in params: - if param not in argsAll: - return + for methodName, methodInst in inspect.getmembers(self, predicate=inspect.ismethod): + apiVersionLast = 0 + apiNameLast = None - return handler(**params) + if getattr(methodInst, 'api', False): + for apiVersion, apiName in getattr(methodInst, 'versions', []): + if apiVersionLast < apiVersion <= version: + apiVersionLast = apiVersion + apiNameLast = apiName + + if apiNameLast is None and apiVersionLast == 0: + apiNameLast = methodName + + if apiNameLast is not None and apiNameLast == name: + method = methodInst + break + + if method is None: + raise Exception('unsupported action') + else: + reply['result'] = methodInst(**params) + except Exception as e: + reply['error'] = str(e) + + if version > 4: + return reply + else: + return reply['result'] - @webApi - def deckNames(self): - return self.anki.deckNames() - - - @webApi - def modelNames(self): - return self.anki.modelNames() - - - @webApi - def modelFieldNames(self, modelName): - return self.anki.modelFieldNames(modelName) - - - @webApi + @webApi() def multi(self, actions): return self.anki.multi(actions) - @webApi + @webApi() + def storeMediaFile(self, filename, data): + return self.anki.storeMediaFile(filename, data) + + + @webApi() + def retrieveMediaFile(self, filename): + return self.anki.retrieveMediaFile(filename) + + + @webApi() + def deleteMediaFile(self, filename): + return self.anki.deleteMediaFile(filename) + + + @webApi() + def deckNames(self): + return self.anki.deckNames() + + + @webApi() + def deckNamesAndIds(self): + return self.anki.deckNamesAndIds() + + + @webApi() + def modelNames(self): + return self.anki.modelNames() + + + @webApi() + def modelNamesAndIds(self): + return self.anki.modelNamesAndIds() + + + @webApi() + def modelFieldNames(self, modelName): + return self.anki.modelFieldNames(modelName) + + + @webApi() + def modelFieldsOnTemplates(self, modelName): + return self.anki.modelFieldsOnTemplates(modelName) + + + @webApi() + def getDeckConfig(self, deck): + return self.anki.getDeckConfig(deck) + + + @webApi() + def saveDeckConfig(self, config): + return self.anki.saveDeckConfig(config) + + + @webApi() + def setDeckConfigId(self, decks, configId): + return self.anki.setDeckConfigId(decks, configId) + + + @webApi() + def cloneDeckConfigId(self, name, cloneFrom=1): + return self.anki.cloneDeckConfigId(name, cloneFrom) + + + @webApi() + def removeDeckConfigId(self, configId): + return self.anki.removeDeckConfigId(configId) + + + @webApi() def addNote(self, note): params = AnkiNoteParams(note) if params.validate(): return self.anki.addNote(params) - @webApi + @webApi() def addNotes(self, notes): results = [] for note in notes: - params = AnkiNoteParams(note) - if params.validate(): - results.append(self.anki.addNote(params)) - else: + try: + params = AnkiNoteParams(note) + if params.validate(): + results.append(self.anki.addNote(params)) + else: + results.append(None) + except Exception: results.append(None) return results - @webApi + @webApi() + def updateNoteFields(self, note): + return self.anki.updateNoteFields(note) + + + @webApi() def canAddNotes(self, notes): results = [] for note in notes: @@ -798,42 +1161,47 @@ class AnkiConnect: return results - @webApi + @webApi() def addTags(self, notes, tags, add=True): return self.anki.addTags(notes, tags, add) - @webApi + @webApi() def removeTags(self, notes, tags): return self.anki.addTags(notes, tags, False) - @webApi + @webApi() + def getTags(self): + return self.anki.getTags() + + + @webApi() def suspend(self, cards, suspend=True): return self.anki.suspend(cards, suspend) - @webApi + @webApi() def unsuspend(self, cards): return self.anki.suspend(cards, False) - @webApi + @webApi() def areSuspended(self, cards): return self.anki.areSuspended(cards) - @webApi + @webApi() def areDue(self, cards): return self.anki.areDue(cards) - @webApi + @webApi() def getIntervals(self, cards, complete=False): return self.anki.getIntervals(cards, complete) - @webApi + @webApi() def upgrade(self): response = QMessageBox.question( self.anki.window(), @@ -856,91 +1224,116 @@ class AnkiConnect: return False - @webApi + @webApi() def version(self): return API_VERSION - @webApi + @webApi() def findNotes(self, query=None): return self.anki.findNotes(query) - @webApi + @webApi() def findCards(self, query=None): return self.anki.findCards(query) - @webApi + @webApi() def getDecks(self, cards): return self.anki.getDecks(cards) - @webApi + @webApi() + def createDeck(self, deck): + return self.anki.createDeck(deck) + + + @webApi() def changeDeck(self, cards, deck): return self.anki.changeDeck(cards, deck) - @webApi + @webApi() def deleteDecks(self, decks, cardsToo=False): return self.anki.deleteDecks(decks, cardsToo) - @webApi + @webApi() def cardsToNotes(self, cards): return self.anki.cardsToNotes(cards) - @webApi + @webApi() def guiBrowse(self, query=None): return self.anki.guiBrowse(query) - @webApi + @webApi() def guiAddCards(self): return self.anki.guiAddCards() - @webApi + @webApi() def guiCurrentCard(self): return self.anki.guiCurrentCard() - @webApi + @webApi() def guiStartCardTimer(self): return self.anki.guiStartCardTimer() - @webApi + @webApi() def guiAnswerCard(self, ease): return self.anki.guiAnswerCard(ease) - @webApi + @webApi() def guiShowQuestion(self): return self.anki.guiShowQuestion() - @webApi + @webApi() def guiShowAnswer(self): return self.anki.guiShowAnswer() - @webApi + @webApi() def guiDeckOverview(self, name): return self.anki.guiDeckOverview(name) - @webApi + @webApi() def guiDeckBrowser(self): return self.anki.guiDeckBrowser() - @webApi + @webApi() def guiDeckReview(self, name): return self.anki.guiDeckReview(name) + @webApi() + def guiExitAnki(self): + return self.anki.guiExitAnki() + + + @webApi() + def cardsInfo(self, cards): + return self.anki.cardsInfo(cards) + + + @webApi() + def notesInfo(self, notes): + return self.anki.notesInfo(notes) + + + @webApi() + def sync(self): + return self.anki.sync() + + # # Entry # diff --git a/README.md b/README.md index e16faf1..cb7b8f6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,23 @@ The AnkiConnect plugin enables external applications such as [Yomichan](https:// the user's card deck, automatically create new vocabulary and Kanji flash cards, and more. AnkiConnect is compatible with the latest stable (2.0.x) and alpha (2.1.x) releases of Anki and works on Linux, Windows, and Mac OS X. +## Table of Contents ## + +* [Installation](https://foosoft.net/projects/anki-connect/#installation) + * [Notes for Windows Users](https://foosoft.net/projects/anki-connect/#notes-for-windows-users) + * [Notes for Mac OS X Users](https://foosoft.net/projects/anki-connect/#notes-for-mac-os-x-users) +* [Application Interface for Developers](https://foosoft.net/projects/anki-connect/#application-interface-for-developers) + * [Sample Invocation](https://foosoft.net/projects/anki-connect/#sample-invocation) + * [Supported Actions](https://foosoft.net/projects/anki-connect/#supported-actions) + * [Miscellaneous](https://foosoft.net/projects/anki-connect/#miscellaneous) + * [Decks](https://foosoft.net/projects/anki-connect/#decks) + * [Models](https://foosoft.net/projects/anki-connect/#models) + * [Notes](https://foosoft.net/projects/anki-connect/#notes) + * [Cards](https://foosoft.net/projects/anki-connect/#cards) + * [Media](https://foosoft.net/projects/anki-connect/#media) + * [Graphical](https://foosoft.net/projects/anki-connect/#graphical) +* [License](https://foosoft.net/projects/anki-connect/#license) + ## Installation ## The installation process is similar to that of other Anki plugins and can be accomplished in three steps: @@ -15,7 +32,7 @@ The installation process is similar to that of other Anki plugins and can be acc Anki must be kept running in the background in order for other applications to be able to use AnkiConnect. You can verify that AnkiConnect is running at any time by accessing [localhost:8765](http://localhost:8765) in your browser. If -the server is running, you should see the message *AnkiConnect v.3* displayed in your browser window. +the server is running, you should see the message *AnkiConnect v.5* displayed in your browser window. ### Notes for Windows Users ### @@ -45,131 +62,165 @@ AnkiConnect exposes Anki features to external applications via an easy to use initialize a minimal HTTP sever running on port 8765 every time Anki executes. Other applications (including browser extensions) can then communicate with it via HTTP POST requests. +By default, AnkiConnect will only bind the HTTP server to the `127.0.0.1` IP address, so that you will only be able to +access it from the same host on which it is running. If you need to access it over a network, you can set the +environment variable `ANKICONNECT_BIND_ADDRESS` to change the binding address. For example, you can set it to `0.0.0.0` +in order to bind it to all network interfaces on your host. + ### Sample Invocation ### -Every request consists of a JSON-encoded object containing an *action*, and a set of contextual *parameters*. A simple -example of a JavaScript application communicating with the extension is illustrated below: +Every request consists of a JSON-encoded object containing an `action`, a `version`, and a set of contextual `params`. A +simple example of a modern JavaScript application communicating with the extension is illustrated below: -```JavaScript -function ankiInvoke(action, params={}) { +```javascript +function ankiConnectInvoke(action, version, params={}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.addEventListener('loadend', () => { - if (xhr.responseText) { - resolve(JSON.parse(xhr.responseText)); - } else { - reject('unable to connect to plugin'); + xhr.addEventListener('error', () => reject('failed to connect to AnkiConnect')); + xhr.addEventListener('load', () => { + try { + const response = JSON.parse(xhr.responseText); + if (response.error) { + throw response.error; + } else { + if (response.hasOwnProperty('result')) { + resolve(response.result); + } else { + reject('failed to get results from AnkiConnect'); + } + } + } catch (e) { + reject(e); } }); xhr.open('POST', 'http://127.0.0.1:8765'); - xhr.send(JSON.stringify({action, params})); + xhr.send(JSON.stringify({action, version, params})); }); } -ankiInvoke('version').then(response => { - window.alert(`detected API version: ${response}`); -}).catch(error => { - window.alert(`could not get API version: ${error}`); -}); +try { + const result = await ankiConnectInvoke('deckNames', 5); + console.log(`got list of decks: ${result}`); +} catch (e) { + console.log(`error getting decks: ${e}`); +} ``` -Or using [`curl`](https://curl.haxx.se): +Or using [`curl`](https://curl.haxx.se) from the command line: +```bash +curl localhost:8765 -X POST -d "{\"action\": \"deckNames\", \"version\": 5}" ``` -curl localhost:8765 -X POST -d '{"action": "version"}' + +AnkiConnect will respond with an object containing two fields: `result` and `error`. The `result` field contains the +return value of the executed API, and the `error` field is a description of any exception thrown during API execution +(the value `null` is used if execution completed successfully). + +*Sample successful response*: +```json +{"result": ["Default", "Filtered Deck 1"], "error": null} ``` +*Samples of failed responses*: +```json +{"result": null, "error": "unsupported action"} +``` +```json +{"result": null, "error": "guiBrowse() got an unexpected keyword argument 'foobar'"} +``` + +For compatibility with clients designed to work with older versions of AnkiConnect, failing to provide a `version` field +in the request will make the version default to 4. Furthermore, when the provided version is level 4 or below, the API +response will only contain the value of the `result`; no `error` field is available for error handling. + ### Supported Actions ### -Below is a list of currently supported actions. Requests with invalid actions or parameters will a return `null` result. +Below is a comprehensive list of currently supported actions. Note that deprecated APIs will continue to function +despite not being listed on this page as long as your request is labeled with a version number corresponding to when the +API was available for use. + +This page currently documents **version 5** of the API. Make sure to include this version number in your requests to +guarantee that your application continues to function properly in the future. + +#### Miscellaneous #### * **version** - Gets the version of the API exposed by this plugin. Currently versions `1` through `4` are defined. + Gets the version of the API exposed by this plugin. Currently versions `1` through `5` are defined. This should be the first call you make to make sure that your application and AnkiConnect are able to communicate properly with each other. New versions of AnkiConnect are backwards compatible; as long as you are using actions which are available in the reported AnkiConnect version or earlier, everything should work fine. *Sample request*: - ``` + ```json { - "action": "version" + "action": "version", + "version": 5 } ``` - *Sample response*: + *Sample result*: + ```json + { + "result": 5, + "error": null + } ``` - 4 - ``` -* **deckNames** - Gets the complete list of deck names for the current user. +* **upgrade** + + Displays a confirmation dialog box in Anki asking the user if they wish to upgrade AnkiConnect to the latest version + from the project's [master branch](https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py) on + GitHub. Returns a boolean value indicating if the plugin was upgraded or not. *Sample request*: - ``` + ```json { - "action": "deckNames" + "action": "upgrade", + "version": 5 } ``` - *Sample response*: - ``` - [ - "Default" - ] + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` -* **modelNames** +* **sync** - Gets the complete list of model names for the current user. + Synchronizes the local anki collections with ankiweb. *Sample request*: - ``` + ```json { - "action": "modelNames" + "action": "sync", + "version": 5 + } ``` - *Sample response*: - ``` - [ - "Basic", - "Basic (and reversed card)" - ] - ``` - -* **modelFieldNames** - - Gets the complete list of field names for the provided model name. - - *Sample request*: - ``` + *Sample result*: + ```json { - "action": "modelFieldNames", - "params": { - "modelName": "Basic" - } + "result": null, + "error": null } ``` - *Sample response*: - ``` - [ - "Front", - "Back" - ] - ``` - * **multi** Performs multiple actions in one request, returning an array with the response of each action (in the given order). *Sample request*: - ``` + ```json { "action": "multi", + "version": 5, "params": { "actions": [ {"action": "deckNames"}, @@ -182,52 +233,492 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response*: + *Sample result*: + ```json + { + "result": [ + ["Default"], + [1494723142483, 1494703460437, 1494703479525] + ], + "error": null + } ``` - [ - ["Default"], - [1494723142483, 1494703460437, 1494703479525] - ] + +#### Decks #### + +* **deckNames** + + Gets the complete list of deck names for the current user. + + *Sample request*: + ```json + { + "action": "deckNames", + "version": 5 + } ``` + *Sample result*: + ```json + { + "result": ["Default"], + "error": null + } + ``` + +* **deckNamesAndIds** + + Gets the complete list of deck names and their respective IDs for the current user. + + *Sample request*: + ```json + { + "action": "deckNamesAndIds", + "version": 5 + } + ``` + + *Sample result*: + ```json + { + "result": {"Default": 1}, + "error": null + } + ``` + +* **getDecks** + + Accepts an array of card IDs and returns an object with each deck name as a key, and its value an array of the given + cards which belong to it. + + *Sample request*: + ```json + { + "action": "getDecks", + "version": 5, + "params": { + "cards": [1502298036657, 1502298033753, 1502032366472] + } + } + ``` + + *Sample result*: + ```json + { + "result": { + "Default": [1502032366472], + "Japanese::JLPT N3": [1502298036657, 1502298033753] + }, + "error": null + } + ``` + +* **createDeck** + + Create a new empty deck. Will not overwrite a deck that exists with the same name. + + *Sample request*: + ```json + { + "action": "createDeck", + "version": 5, + "params": { + "deck": "Japanese::Tokyo" + } + } + ``` + + *Sample result*: + ```json + { + "result": 1519323742721, + "error": null + } + ``` +* **changeDeck** + + Moves cards with the given IDs to a different deck, creating the deck if it doesn't exist yet. + + *Sample request*: + ```json + { + "action": "changeDeck", + "version": 5, + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753], + "deck": "Japanese::JLPT N3" + } + } + ``` + + *Sample result*: + ```json + { + "result": null, + "error": null + } + ``` + +* **deleteDecks** + + Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within + the deleted decks will also be deleted; otherwise they will be moved to the default deck. + + *Sample request*: + ```json + { + "action": "deleteDecks", + "version": 5, + "params": { + "decks": ["Japanese::JLPT N5", "Easy Spanish"], + "cardsToo": true + } + } + ``` + + *Sample result*: + ```json + { + "result": null, + "error": null + } + ``` + +* **getDeckConfig** + + Gets the configuration group object for the given deck. + + *Sample request*: + ```json + { + "action": "getDeckConfig", + "version": 5, + "params": { + "deck": "Default" + } + } + ``` + + *Sample result*: + ```json + { + "result": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + }, + "error": null + } + ``` + +* **saveDeckConfig** + + Saves the given configuration group, returning `true` on success or `false` if the ID of the configuration group is + invalid (such as when it does not exist). + + *Sample request*: + ```json + { + "action": "saveDeckConfig", + "version": 5, + "params": { + "config": { + "lapse": { + "leechFails": 8, + "delays": [10], + "minInt": 1, + "leechAction": 0, + "mult": 0 + }, + "dyn": false, + "autoplay": true, + "mod": 1502970872, + "id": 1, + "maxTaken": 60, + "new": { + "bury": true, + "order": 1, + "initialFactor": 2500, + "perDay": 20, + "delays": [1, 10], + "separate": true, + "ints": [1, 4, 7] + }, + "name": "Default", + "rev": { + "bury": true, + "ivlFct": 1, + "ease4": 1.3, + "maxIvl": 36500, + "perDay": 100, + "minSpace": 1, + "fuzz": 0.05 + }, + "timer": 0, + "replayq": true, + "usn": -1 + } + } + } + ``` + + *Sample result*: + ```json + { + "result": true, + "error": null + } + ``` + +* **setDeckConfigId** + + Changes the configuration group for the given decks to the one with the given ID. Returns `true` on success or + `false` if the given configuration group or any of the given decks do not exist. + + *Sample request*: + ```json + { + "action": "setDeckConfigId", + "version": 5, + "params": { + "decks": ["Default"], + "configId": 1 + } + } + ``` + + *Sample result*: + ```json + { + "result": true, + "error": null + } + ``` + +* **cloneDeckConfigId** + + Creates a new configuration group with the given name, cloning from the group with the given ID, or from the default + group if this is unspecified. Returns the ID of the new configuration group, or `false` if the specified group to + clone from does not exist. + + *Sample request*: + ```json + { + "action": "cloneDeckConfigId", + "version": 5, + "params": { + "name": "Copy of Default", + "cloneFrom": 1 + } + } + ``` + + *Sample result*: + ```json + { + "result": 1502972374573, + "error": null + } + ``` + +* **removeDeckConfigId** + + Removes the configuration group with the given ID, returning `true` if successful, or `false` if attempting to + remove either the default configuration group (ID = 1) or a configuration group that does not exist. + + *Sample request*: + ```json + { + "action": "removeDeckConfigId", + "version": 5, + "params": { + "configId": 1502972374573 + } + } + ``` + + *Sample result*: + ```json + { + "result": true, + "error": null + } + ``` + +#### Models #### + +* **modelNames** + + Gets the complete list of model names for the current user. + + *Sample request*: + ```json + { + "action": "modelNames", + "version": 5 + } + ``` + + *Sample result*: + ```json + { + "result": ["Basic", "Basic (and reversed card)"], + "error": null + } + ``` + +* **modelNamesAndIds** + + Gets the complete list of model names and their corresponding IDs for the current user. + + *Sample request*: + ```json + { + "action": "modelNamesAndIds", + "version": 5 + } + ``` + + *Sample result*: + ```json + { + "result": { + "Basic": 1483883011648, + "Basic (and reversed card)": 1483883011644, + "Basic (optional reversed card)": 1483883011631, + "Cloze": 1483883011630 + }, + "error": null + } + ``` + +* **modelFieldNames** + + Gets the complete list of field names for the provided model name. + + *Sample request*: + ```json + { + "action": "modelFieldNames", + "version": 5, + "params": { + "modelName": "Basic" + } + } + ``` + + *Sample result*: + ```json + { + "result": ["Front", "Back"], + "error": null + } + ``` + +* **modelFieldsOnTemplates** + + Returns an object indicating the fields on the question and answer side of each card template for the given model + name. The question side is given first in each array. + + *Sample request*: + ```json + { + "action": "modelFieldsOnTemplates", + "version": 5, + "params": { + "modelName": "Basic (and reversed card)" + } + } + ``` + + *Sample result*: + ```json + { + "result": { + "Card 1": [["Front"], ["Back"]], + "Card 2": [["Back"], ["Front"]] + }, + "error": null + } + ``` + +#### Notes #### + * **addNote** Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of the created note created on success, and `null` on failure. - AnkiConnect can download audio files and embed them in newly created notes. The corresponding *audio* note member is - optional and can be omitted. If you choose to include it, the *url* and *filename* fields must be also defined. The - *skipHash* field can be optionally provided to skip the inclusion of downloaded files with an MD5 hash that matches - the provided value. This is useful for avoiding the saving of error pages and stub files. + AnkiConnect can download audio files and embed them in newly created notes. The corresponding `audio` note member is + optional and can be omitted. If you choose to include it, the `url` and `filename` fields must be also defined. The + `skipHash` field can be optionally provided to skip the inclusion of downloaded files with an MD5 hash that matches + the provided value. This is useful for avoiding the saving of error pages and stub files. The `fields` member is a + list of fields that should play audio when the card is displayed in Anki. *Sample request*: - ``` + ```json { "action": "addNote", + "version": 5, "params": { "note": { "deckName": "Default", "modelName": "Basic", "fields": { "Front": "front content", - "Back": "back content", + "Back": "back content" }, "tags": [ - "yomichan", + "yomichan" ], "audio": { "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", "filename": "yomichan_ねこ_猫.mp3", - "skipHash": "7e2c2f954ef6051373ba916f000168dc" + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": "Front" } } } } ``` - *Sample response*: - ``` - 1496198395707 + *Sample result*: + ```json + { + "result": 1496198395707, + "error": null + } ``` * **addNotes** @@ -237,9 +728,10 @@ Below is a list of currently supported actions. Requests with invalid actions or documentation for `addNote` for an explanation of objects in the `notes` array. *Sample request*: - ``` + ```json { "action": "addNotes", + "version": 5, "params": { "notes": [ { @@ -255,7 +747,8 @@ Below is a list of currently supported actions. Requests with invalid actions or "audio": { "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", "filename": "yomichan_ねこ_猫.mp3", - "skipHash": "7e2c2f954ef6051373ba916f000168dc" + "skipHash": "7e2c2f954ef6051373ba916f000168dc", + "fields": "Front" } } ] @@ -263,12 +756,12 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response*: - ``` - [ - 1496198395707, - null - ] + *Sample result*: + ```json + { + "result": [1496198395707, null], + "error": null + } ``` * **canAddNotes** @@ -277,9 +770,10 @@ Below is a list of currently supported actions. Requests with invalid actions or booleans indicating whether or not the parameters at the corresponding index could be used to create a new note. *Sample request*: - ``` + ```json { "action": "canAddNotes", + "version": 5, "params": { "notes": [ { @@ -298,11 +792,41 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response*: + *Sample result*: + ```json + { + "result": [true], + "error": null + } ``` - [ - true - ] + +* **updateNoteFields** + + Modify the fields of an exist note. + + *Sample request*: + ```json + { + "action": "updateNoteFields", + "version": 5, + "params": { + "note": { + "id": 1514547547030, + "fields": { + "Front": "new front content", + "Back": "new back content" + } + } + } + } + ``` + + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` * **addTags** @@ -310,9 +834,10 @@ Below is a list of currently supported actions. Requests with invalid actions or Adds tags to notes by note ID. *Sample request*: - ``` + ```json { "action": "addTags", + "version": 5, "params": { "notes": [1483959289817, 1483959291695], "tags": "european-languages" @@ -320,9 +845,12 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response*: - ``` - null + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` * **removeTags** @@ -330,9 +858,10 @@ Below is a list of currently supported actions. Requests with invalid actions or Remove tags from notes by note ID. *Sample request*: - ``` + ```json { "action": "removeTags", + "version": 5, "params": { "notes": [1483959289817, 1483959291695], "tags": "european-languages" @@ -340,29 +869,116 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response*: + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` - null + +* **getTags** + + Gets the complete list of tags for the current user. + + *Sample request*: + ```json + { + "action": "getTags", + "version": 5 + } ``` + *Sample result*: + ```json + { + "result": ["european-languages", "idioms"], + "error": null + } + ``` + +* **findNotes** + + Returns an array of note IDs for a given query. Same query syntax as `guiBrowse`. + + *Sample request*: + ```json + { + "action": "findNotes", + "version": 5, + "params": { + "query": "deck:current" + } + } + ``` + + *Sample result*: + ```json + { + "result": [1483959289817, 1483959291695], + "error": null + } + ``` + +* **notesInfo** + + Returns a list of objects containing for each note ID the note fields, tags, note type and the cards belonging to + the note. + + *Sample request*: + ```json + { + "action": "notesInfo", + "version": 5, + "params": { + "notes": [1502298033753] + } + } + ``` + + *Sample result*: + ```json + { + "result": [ + { + "noteId":1502298033753, + "modelName": "Basic", + "tags":["tag","another_tag"], + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + } + } + ], + "error": null + } + ``` + + +#### Cards #### + * **suspend** Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` otherwise. *Sample request*: - ``` + ```json { "action": "suspend", + "version": 5, "params": { "cards": [1483959291685, 1483959293217] } } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **unsuspend** @@ -371,18 +987,22 @@ Below is a list of currently supported actions. Requests with invalid actions or otherwise. *Sample request*: - ``` + ```json { "action": "unsuspend", + "version": 5, "params": { "cards": [1483959291685, 1483959293217] } } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **areSuspended** @@ -390,65 +1010,78 @@ Below is a list of currently supported actions. Requests with invalid actions or Returns an array indicating whether each of the given cards is suspended (in the same order). *Sample request*: - ``` + ```json { "action": "areSuspended", + "version": 5, "params": { "cards": [1483959291685, 1483959293217] } } ``` - *Sample response*: - ``` - [false, true] + *Sample result*: + ```json + { + "result": [false, true], + "error": null + } ``` * **areDue** - Returns an array indicating whether each of the given cards is due (in the same order). Note: cards in the learning - queue with a large interval (over 20 minutes) are treated as not due until the time of their interval has passed, to - match the way Anki treats them when reviewing. + Returns an array indicating whether each of the given cards is due (in the same order). *Note*: cards in the + learning queue with a large interval (over 20 minutes) are treated as not due until the time of their interval has + passed, to match the way Anki treats them when reviewing. *Sample request*: - ``` + ```json { "action": "areDue", + "version": 5, "params": { "cards": [1483959291685, 1483959293217] } } ``` - *Sample response*: - ``` - [false, true] + *Sample result*: + ```json + { + "result": [false, true], + "error": null + } ``` * **getIntervals** Returns an array of the most recent intervals for each given card ID, or a 2-dimensional array of all the intervals - for each given card ID when `complete` is `true`. (Negative intervals are in seconds and positive intervals in days.) + for each given card ID when `complete` is `true`. Negative intervals are in seconds and positive intervals in days. *Sample request 1*: - ``` + ```json { "action": "getIntervals", + "version": 5, "params": { "cards": [1502298033753, 1502298036657] } } ``` - *Sample response 1*: - ``` - [-14400, 3] + *Sample result 1*: + ```json + { + "result": [-14400, 3], + "error": null + } ``` *Sample request 2*: - ``` + ```json { "action": "getIntervals", + "version": 5, "params": { "cards": [1502298033753, 1502298036657], "complete": true @@ -456,186 +1089,243 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` - *Sample response 2*: - ``` - [ - [-120, -180, -240, -300, -360, -14400], - [-120, -180, -240, -300, -360, -14400, 1, 3] - ] - ``` - - -* **findNotes** - - Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). - - *Sample request*: - ``` + *Sample result 2*: + ```json { - "action": "findNotes", - "params": { - "query": "deck:current" - } + "result": [ + [-120, -180, -240, -300, -360, -14400], + [-120, -180, -240, -300, -360, -14400, 1, 3] + ], + "error": null } ``` - *Sample response*: - ``` - [ - 1483959289817, - 1483959291695 - ] - ``` - * **findCards** - Returns an array of card IDs for a given query (functionally identical to **guiBrowse** but doesn't use the GUI - for better performance). + Returns an array of card IDs for a given query. Functionally identical to `guiBrowse` but doesn't use the GUI for + better performance. *Sample request*: - ``` + ```json { "action": "findCards", + "version": 5, "params": { "query": "deck:current" } } ``` - *Sample response*: - ``` - [ - 1494723142483, - 1494703460437, - 1494703479525 - ] - ``` - -* **getDecks** - - Accepts an array of card IDs and returns an object with each deck name as a key, and its value an array of the given - cards which belong to it. - - *Sample request*: - ``` + *Sample result*: + ```json { - "action": "getDecks", - "params": { - "cards": [1502298036657, 1502298033753, 1502032366472] - } + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null } ``` - *Sample response*: - ``` - { - "Default": [1502032366472], - "Japanese::JLPT N3": [1502298036657, 1502298033753] - } - ``` - - -* **changeDeck** - - Moves cards with the given IDs to a different deck, creating the deck if it doesn't exist yet. - - *Sample request*: - ``` - { - "action": "changeDeck", - "params": { - "cards": [1502098034045, 1502098034048, 1502298033753], - "deck": "Japanese::JLPT N3" - } - } - ``` - - *Sample response*: - ``` - null - ``` - -* **deleteDecks** - - Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within - the deleted decks will also be deleted; otherwise they will be moved to the default deck. - - *Sample request*: - ``` - { - "action": "deleteDecks", - "params": { - "decks": ["Japanese::JLPT N5", "Easy Spanish"], - "cardsToo": true - } - } - ``` - - *Sample response*: - ``` - null - ``` - * **cardsToNotes** - Returns an (unordered) array of note IDs for the given card IDs. For cards with the same note, the ID is only - given once in the array. + Returns an unordered array of note IDs for the given card IDs. For cards with the same note, the ID is only given + once in the array. *Sample request*: - ``` + ```json { "action": "cardsToNotes", + "version": 5, "params": { "cards": [1502098034045, 1502098034048, 1502298033753] } } ``` - *Sample response*: + *Sample result*: + ```json + { + "result": [1502098029797, 1502298025183], + "error": null + } ``` - [ - 1502098029797, - 1502298025183 - ] + +* **cardsInfo** + + Returns a list of objects containing for each card ID the card fields, front and back sides including CSS, note + type, the note that the card belongs to, and deck name, as well as ease and interval. + + *Sample request*: + ```json + { + "action": "cardsInfo", + "version": 5, + "params": { + "cards": [1498938915662, 1502098034048] + } + } ``` + *Sample result*: + ```json + { + "result": [ + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 1, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1498938915662, + "interval": 16, + "note":1502298033753 + }, + { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} + }, + "css":"p {font-family:Arial;}", + "cardId": 1502098034048, + "interval": 23, + "note":1502298033753 + } + ], + "error": null + } + ``` + +#### Media #### + +* **storeMediaFile** + + Stores a file with the specified base64-encoded contents inside the media folder. To prevent Anki from removing + files not used by any cards (e.g. for configuration files), prefix the filename with an underscore. These files are + still synchronized to AnkiWeb. + + *Sample request*: + ```json + { + "action": "storeMediaFile", + "version": 5, + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *Sample result*: + ```json + { + "result": null, + "error": null + } + ``` + + *Content of `_hello.txt`*: + ``` + Hello world! + ``` + +* **retrieveMediaFile** + + Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist. + + *Sample request*: + ```json + { + "action": "retrieveMediaFile", + "version": 5, + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample result*: + ```json + { + "result": "SGVsbG8sIHdvcmxkIQ==", + "error": null + } + ``` + +* **deleteMediaFile** + + Deletes the specified file inside the media folder. + + *Sample request*: + ```json + { + "action": "deleteMediaFile", + "version": 5, + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample result*: + ```json + { + "result": null, + "error": null + } + ``` + +#### Graphical #### + * **guiBrowse** - Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. + Invokes the *Card Browser* dialog and searches for a given query. Returns an array of identifiers of the cards that + were found. *Sample request*: - ``` + ```json { "action": "guiBrowse", + "version": 5, "params": { "query": "deck:current" } } ``` - *Sample response*: - ``` - [ - 1494723142483, - 1494703460437, - 1494703479525 - ] + *Sample result*: + ```json + { + "result": [1494723142483, 1494703460437, 1494703479525], + "error": null + } ``` * **guiAddCards** - Invokes the AddCards dialog. + Invokes the *Add Cards* dialog. *Sample request*: - ``` + ```json { - "action": "guiAddCards" + "action": "guiAddCards", + "version": 5 } ``` - *Sample response*: - ``` - null + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` * **guiCurrentCard** @@ -643,49 +1333,53 @@ Below is a list of currently supported actions. Requests with invalid actions or Returns information about the current card or `null` if not in review mode. *Sample request*: - ``` + ```json { - "action": "guiCurrentCard" + "action": "guiCurrentCard", + "version": 5 } ``` - *Sample response*: - ``` + *Sample result*: + ```json { - "answer": "back content", - "question": "front content", - "deckName": "Default", - "modelName": "Basic", - "fieldOrder": 0, - "fields": { - "Front": { - "value": "front content", - "order": 0 + "result": { + "answer": "back content", + "question": "front content", + "deckName": "Default", + "modelName": "Basic", + "fieldOrder": 0, + "fields": { + "Front": {"value": "front content", "order": 0}, + "Back": {"value": "back content", "order": 1} }, - "Back": { - "value": "back content", - "order": 1 - } + "cardId": 1498938915662, + "buttons": [1, 2, 3] }, - "cardId": 1498938915662, - "buttons": [1, 2, 3] + "error": null } ``` * **guiStartCardTimer** - Starts or resets the 'timerStarted' value for the current card. This is useful for deferring the start time to when it is displayed via the API, allowing the recorded time taken to answer the card to be more accurate when calling guiAnswerCard. + Starts or resets the `timerStarted` value for the current card. This is useful for deferring the start time to when + it is displayed via the API, allowing the recorded time taken to answer the card to be more accurate when calling + `guiAnswerCard`. *Sample request*: - ``` + ```json { - "action": "guiStartCardTimer" + "action": "guiStartCardTimer", + "version": 5 } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **guiShowQuestion** @@ -693,15 +1387,19 @@ Below is a list of currently supported actions. Requests with invalid actions or Shows question text for the current card; returns `true` if in review mode or `false` otherwise. *Sample request*: - ``` + ```json { - "action": "guiShowQuestion" + "action": "guiShowQuestion", + "version": 5 } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **guiShowAnswer** @@ -709,15 +1407,19 @@ Below is a list of currently supported actions. Requests with invalid actions or Shows answer text for the current card; returns `true` if in review mode or `false` otherwise. *Sample request*: - ``` + ```json { - "action": "guiShowAnswer" + "action": "guiShowAnswer", + "version": 5 } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **guiAnswerCard** @@ -726,53 +1428,65 @@ Below is a list of currently supported actions. Requests with invalid actions or card must be displayed before before any answer can be accepted by Anki. *Sample request*: - ``` + ```json { "action": "guiAnswerCard", + "version": 5, "params": { "ease": 1 } } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **guiDeckOverview** - Opens the Deck Overview screen for the deck with the given name; returns `true` if succeeded or `false` otherwise. + Opens the *Deck Overview* dialog for the deck with the given name; returns `true` if succeeded or `false` otherwise. *Sample request*: - ``` + ```json { "action": "guiDeckOverview", + "version": 5, "params": { "name": "Default" } } ``` - *Sample response*: - ``` - true + *Sample result*: + ```json + { + "result": true, + "error": null + } ``` * **guiDeckBrowser** - Opens the Deck Browser screen. + Opens the *Deck Browser* dialog. *Sample request*: - ``` + ```json { - "action": "guiDeckBrowser" + "action": "guiDeckBrowser", + "version": 5 } ``` - *Sample response*: - ``` - null + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` * **guiDeckReview** @@ -780,36 +1494,43 @@ Below is a list of currently supported actions. Requests with invalid actions or Starts review for the deck with the given name; returns `true` if succeeded or `false` otherwise. *Sample request*: - ``` + ```json { "action": "guiDeckReview", + "version": 5, "params": { "name": "Default" } } ``` - *Sample response*: - ``` - true - ``` - -* **upgrade** - - Displays a confirmation dialog box in Anki asking the user if they wish to upgrade AnkiConnect to the latest version - from the project's [master branch](https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py) on - GitHub. Returns a boolean value indicating if the plugin was upgraded or not. - - *Sample request*: - ``` + *Sample result*: + ```json { - "action": "upgrade" + "result": true, + "error": null } ``` - *Sample response*: +* **guiExitAnki** + + Schedules a request to gracefully close Anki. This operation is asynchronous, so it will return immediately and + won't wait until the Anki process actually terminates. + + *Sample request*: + ```json + { + "action": "guiExitAnki", + "version": 5 + } ``` - true + + *Sample result*: + ```json + { + "result": null, + "error": null + } ``` ## License ## diff --git a/build_zip.sh b/build_zip.sh new file mode 100755 index 0000000..2f6d2ab --- /dev/null +++ b/build_zip.sh @@ -0,0 +1,5 @@ +#!/usr/bin/bash +rm AnkiConnect.zip +cp AnkiConnect.py __init__.py +7za a AnkiConnect.zip __init__.py +rm __init__.py diff --git a/tests/docker/2.0.x/Dockerfile b/tests/docker/2.0.x/Dockerfile new file mode 100644 index 0000000..650c050 --- /dev/null +++ b/tests/docker/2.0.x/Dockerfile @@ -0,0 +1,14 @@ +FROM txgio/anki:2.0.45 + +RUN apt-get update && \ + apt-get install -y xvfb + +COPY AnkiConnect.py /data/addons/AnkiConnect.py + +COPY tests/docker/2.0.x/prefs.db /data/prefs.db + +ADD tests/docker/2.0.x/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] + +CMD ["anki", "-b", "/data"] \ No newline at end of file diff --git a/tests/docker/2.0.x/entrypoint.sh b/tests/docker/2.0.x/entrypoint.sh new file mode 100755 index 0000000..8285fb9 --- /dev/null +++ b/tests/docker/2.0.x/entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +# Start Xvfb +Xvfb -ac -screen scrn 1280x2000x24 :99.0 & +export DISPLAY=:99.0 + +exec "$@" \ No newline at end of file diff --git a/tests/docker/2.0.x/prefs.db b/tests/docker/2.0.x/prefs.db new file mode 100644 index 0000000..eee5d70 Binary files /dev/null and b/tests/docker/2.0.x/prefs.db differ diff --git a/tests/docker/2.1.x/Dockerfile b/tests/docker/2.1.x/Dockerfile new file mode 100644 index 0000000..3438c74 --- /dev/null +++ b/tests/docker/2.1.x/Dockerfile @@ -0,0 +1,14 @@ +FROM txgio/anki:2.1.0beta14 + +RUN apt-get update && \ + apt-get install -y xvfb + +COPY AnkiConnect.py /data/addons21/AnkiConnect/__init__.py + +COPY tests/docker/2.1.x/prefs21.db /data/prefs21.db + +ADD tests/docker/2.1.x/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] + +CMD ["anki", "-b", "/data"] \ No newline at end of file diff --git a/tests/docker/2.1.x/entrypoint.sh b/tests/docker/2.1.x/entrypoint.sh new file mode 100755 index 0000000..8285fb9 --- /dev/null +++ b/tests/docker/2.1.x/entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +# Start Xvfb +Xvfb -ac -screen scrn 1280x2000x24 :99.0 & +export DISPLAY=:99.0 + +exec "$@" \ No newline at end of file diff --git a/tests/docker/2.1.x/prefs21.db b/tests/docker/2.1.x/prefs21.db new file mode 100644 index 0000000..dc4d505 Binary files /dev/null and b/tests/docker/2.1.x/prefs21.db differ diff --git a/tests/scripts/wait-up.sh b/tests/scripts/wait-up.sh new file mode 100755 index 0000000..6491b19 --- /dev/null +++ b/tests/scripts/wait-up.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +if [ $# -lt 1 ]; then + printf "First parameter URL required.\n" + exit 1 +fi + +COUNTER=0 +STEP_SIZE=1 +MAX_SECONDS=${2:-10} # Wait 10 seconds if parameter not provided +MAX_RETRIES=$(( $MAX_SECONDS / $STEP_SIZE)) + +URL=$1 + +printf "Waiting URL: "$URL"\n" + +until $(curl --insecure --output /dev/null --silent --fail $URL) || [ $COUNTER -eq $MAX_RETRIES ]; do + printf '.' + sleep $STEP_SIZE + COUNTER=$(($COUNTER + 1)) +done +if [ $COUNTER -eq $MAX_RETRIES ]; then + printf "\nTimeout after "$(( $COUNTER * $STEP_SIZE))" second(s).\n" + exit 2 +else + printf "\nUp successfully after "$(( $COUNTER * $STEP_SIZE))" second(s).\n" +fi \ No newline at end of file diff --git a/tests/test_decks.py b/tests/test_decks.py new file mode 100644 index 0000000..f4a79e8 --- /dev/null +++ b/tests/test_decks.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +import unittest +from unittest import TestCase +from util import callAnkiConnectEndpoint + +class TestDeckNames(TestCase): + + def test_deckNames(self): + response = callAnkiConnectEndpoint({'action': 'deckNames'}) + self.assertEqual(['Default'], response) + +class TestGetDeckConfig(TestCase): + + def test_getDeckConfig(self): + response = callAnkiConnectEndpoint({'action': 'getDeckConfig', 'params': {'deck': 'Default'}}) + self.assertDictContainsSubset({'name': 'Default', 'replayq': True}, response) \ No newline at end of file diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..11c8651 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +import unittest +from unittest import TestCase +from util import callAnkiConnectEndpoint + +class TestVersion(TestCase): + + def test_version(self): + response = callAnkiConnectEndpoint({'action': 'version'}) + self.assertEqual(5, response) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..bf121a0 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,11 @@ +import json +import urllib +import urllib2 + +def callAnkiConnectEndpoint(data): + url = 'http://docker:8888' + dumpedData = json.dumps(data) + req = urllib2.Request(url, dumpedData) + response = urllib2.urlopen(req).read() + responseData = json.loads(response) + return responseData \ No newline at end of file