├── .gitignore ├── tests ├── docker │ ├── 2.0.x │ │ ├── prefs.db │ │ ├── entrypoint.sh │ │ └── Dockerfile │ └── 2.1.x │ │ ├── prefs21.db │ │ ├── entrypoint.sh │ │ └── Dockerfile ├── test_misc.py ├── util.py ├── test_decks.py └── scripts │ └── wait-up.sh ├── .travis.yml ├── AnkiConnect.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /tests/docker/2.0.x/prefs.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amikey/anki-connect/HEAD/tests/docker/2.0.x/prefs.db -------------------------------------------------------------------------------- /tests/docker/2.1.x/prefs21.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amikey/anki-connect/HEAD/tests/docker/2.1.x/prefs21.db -------------------------------------------------------------------------------- /tests/docker/2.0.x/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Start Xvfb 5 | Xvfb -ac -screen scrn 1280x2000x24 :99.0 & 6 | export DISPLAY=:99.0 7 | 8 | exec "$@" -------------------------------------------------------------------------------- /tests/docker/2.1.x/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Start Xvfb 5 | Xvfb -ac -screen scrn 1280x2000x24 :99.0 & 6 | export DISPLAY=:99.0 7 | 8 | exec "$@" -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | from unittest import TestCase 4 | from util import callAnkiConnectEndpoint 5 | 6 | class TestVersion(TestCase): 7 | 8 | def test_version(self): 9 | response = callAnkiConnectEndpoint({'action': 'version'}) 10 | self.assertEqual(5, response) 11 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import urllib 3 | import urllib2 4 | 5 | def callAnkiConnectEndpoint(data): 6 | url = 'http://docker:8888' 7 | dumpedData = json.dumps(data) 8 | req = urllib2.Request(url, dumpedData) 9 | response = urllib2.urlopen(req).read() 10 | responseData = json.loads(response) 11 | return responseData -------------------------------------------------------------------------------- /tests/docker/2.0.x/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM txgio/anki:2.0.45 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y xvfb 5 | 6 | COPY AnkiConnect.py /data/addons/AnkiConnect.py 7 | 8 | COPY tests/docker/2.0.x/prefs.db /data/prefs.db 9 | 10 | ADD tests/docker/2.0.x/entrypoint.sh /entrypoint.sh 11 | 12 | ENTRYPOINT ["/entrypoint.sh"] 13 | 14 | CMD ["anki", "-b", "/data"] -------------------------------------------------------------------------------- /tests/docker/2.1.x/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM txgio/anki:2.1.0beta14 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y xvfb 5 | 6 | COPY AnkiConnect.py /data/addons21/AnkiConnect/__init__.py 7 | 8 | COPY tests/docker/2.1.x/prefs21.db /data/prefs21.db 9 | 10 | ADD tests/docker/2.1.x/entrypoint.sh /entrypoint.sh 11 | 12 | ENTRYPOINT ["/entrypoint.sh"] 13 | 14 | CMD ["anki", "-b", "/data"] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | addons: 4 | hosts: 5 | - docker 6 | services: 7 | - docker 8 | python: 9 | - "2.7" 10 | install: 11 | - docker build -f tests/docker/$ANKI_VERSION/Dockerfile -t txgio/anki-connect:$ANKI_VERSION . 12 | script: 13 | - docker run -ti -d --rm -p 8888:8765 -e ANKICONNECT_BIND_ADDRESS=0.0.0.0 txgio/anki-connect:$ANKI_VERSION 14 | - ./tests/scripts/wait-up.sh http://docker:8888 15 | - python -m unittest discover -s tests -v 16 | 17 | env: 18 | - ANKI_VERSION=2.0.x 19 | - ANKI_VERSION=2.1.x -------------------------------------------------------------------------------- /tests/test_decks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | from unittest import TestCase 4 | from util import callAnkiConnectEndpoint 5 | 6 | class TestDeckNames(TestCase): 7 | 8 | def test_deckNames(self): 9 | response = callAnkiConnectEndpoint({'action': 'deckNames'}) 10 | self.assertEqual(['Default'], response) 11 | 12 | class TestGetDeckConfig(TestCase): 13 | 14 | def test_getDeckConfig(self): 15 | response = callAnkiConnectEndpoint({'action': 'getDeckConfig', 'params': {'deck': 'Default'}}) 16 | self.assertDictContainsSubset({'name': 'Default', 'replayq': True}, response) -------------------------------------------------------------------------------- /tests/scripts/wait-up.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | if [ $# -lt 1 ]; then 5 | printf "First parameter URL required.\n" 6 | exit 1 7 | fi 8 | 9 | COUNTER=0 10 | STEP_SIZE=1 11 | MAX_SECONDS=${2:-10} # Wait 10 seconds if parameter not provided 12 | MAX_RETRIES=$(( $MAX_SECONDS / $STEP_SIZE)) 13 | 14 | URL=$1 15 | 16 | printf "Waiting URL: "$URL"\n" 17 | 18 | until $(curl --insecure --output /dev/null --silent --fail $URL) || [ $COUNTER -eq $MAX_RETRIES ]; do 19 | printf '.' 20 | sleep $STEP_SIZE 21 | COUNTER=$(($COUNTER + 1)) 22 | done 23 | if [ $COUNTER -eq $MAX_RETRIES ]; then 24 | printf "\nTimeout after "$(( $COUNTER * $STEP_SIZE))" second(s).\n" 25 | exit 2 26 | else 27 | printf "\nUp successfully after "$(( $COUNTER * $STEP_SIZE))" second(s).\n" 28 | fi -------------------------------------------------------------------------------- /AnkiConnect.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2016 Alex Yatskov 2 | # Author: Alex Yatskov 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | 17 | 18 | import anki 19 | import aqt 20 | import base64 21 | import hashlib 22 | import inspect 23 | import json 24 | import os 25 | import os.path 26 | import re 27 | import select 28 | import socket 29 | import sys 30 | from time import time 31 | from unicodedata import normalize 32 | from operator import itemgetter 33 | 34 | 35 | # 36 | # Constants 37 | # 38 | 39 | API_VERSION = 5 40 | TICK_INTERVAL = 25 41 | URL_TIMEOUT = 10 42 | URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py' 43 | NET_ADDRESS = os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1') 44 | NET_BACKLOG = 5 45 | NET_PORT = 8765 46 | 47 | 48 | # 49 | # General helpers 50 | # 51 | 52 | if sys.version_info[0] < 3: 53 | import urllib2 54 | web = urllib2 55 | 56 | from PyQt4.QtCore import QTimer 57 | from PyQt4.QtGui import QMessageBox 58 | else: 59 | unicode = str 60 | 61 | from urllib import request 62 | web = request 63 | 64 | from PyQt5.QtCore import QTimer 65 | from PyQt5.QtWidgets import QMessageBox 66 | 67 | 68 | # 69 | # Helpers 70 | # 71 | 72 | def webApi(*versions): 73 | def decorator(func): 74 | method = lambda *args, **kwargs: func(*args, **kwargs) 75 | setattr(method, 'versions', versions) 76 | setattr(method, 'api', True) 77 | return method 78 | return decorator 79 | 80 | 81 | def makeBytes(data): 82 | return data.encode('utf-8') 83 | 84 | 85 | def makeStr(data): 86 | return data.decode('utf-8') 87 | 88 | 89 | def download(url): 90 | try: 91 | resp = web.urlopen(url, timeout=URL_TIMEOUT) 92 | except web.URLError: 93 | return None 94 | 95 | if resp.code != 200: 96 | return None 97 | 98 | return resp.read() 99 | 100 | 101 | def audioInject(note, fields, filename): 102 | for field in fields: 103 | if field in note: 104 | note[field] += u'[sound:{}]'.format(filename) 105 | 106 | 107 | def verifyString(string): 108 | t = type(string) 109 | return t == str or t == unicode 110 | 111 | 112 | def verifyStringList(strings): 113 | for s in strings: 114 | if not verifyString(s): 115 | return False 116 | 117 | return True 118 | 119 | 120 | 121 | # 122 | # AjaxRequest 123 | # 124 | 125 | class AjaxRequest: 126 | def __init__(self, headers, body): 127 | self.headers = headers 128 | self.body = body 129 | 130 | 131 | # 132 | # AjaxClient 133 | # 134 | 135 | class AjaxClient: 136 | def __init__(self, sock, handler): 137 | self.sock = sock 138 | self.handler = handler 139 | self.readBuff = bytes() 140 | self.writeBuff = bytes() 141 | 142 | 143 | def advance(self, recvSize=1024): 144 | if self.sock is None: 145 | return False 146 | 147 | rlist, wlist = select.select([self.sock], [self.sock], [], 0)[:2] 148 | 149 | if rlist: 150 | msg = self.sock.recv(recvSize) 151 | if not msg: 152 | self.close() 153 | return False 154 | 155 | self.readBuff += msg 156 | 157 | req, length = self.parseRequest(self.readBuff) 158 | if req is not None: 159 | self.readBuff = self.readBuff[length:] 160 | self.writeBuff += self.handler(req) 161 | 162 | if wlist and self.writeBuff: 163 | length = self.sock.send(self.writeBuff) 164 | self.writeBuff = self.writeBuff[length:] 165 | if not self.writeBuff: 166 | self.close() 167 | return False 168 | 169 | return True 170 | 171 | 172 | def close(self): 173 | if self.sock is not None: 174 | self.sock.close() 175 | self.sock = None 176 | 177 | self.readBuff = bytes() 178 | self.writeBuff = bytes() 179 | 180 | 181 | def parseRequest(self, data): 182 | parts = data.split(makeBytes('\r\n\r\n'), 1) 183 | if len(parts) == 1: 184 | return None, 0 185 | 186 | headers = {} 187 | for line in parts[0].split(makeBytes('\r\n')): 188 | pair = line.split(makeBytes(': ')) 189 | headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None 190 | 191 | headerLength = len(parts[0]) + 4 192 | bodyLength = int(headers.get(makeBytes('content-length'), 0)) 193 | totalLength = headerLength + bodyLength 194 | 195 | if totalLength > len(data): 196 | return None, 0 197 | 198 | body = data[headerLength : totalLength] 199 | return AjaxRequest(headers, body), totalLength 200 | 201 | 202 | # 203 | # AjaxServer 204 | # 205 | 206 | class AjaxServer: 207 | def __init__(self, handler): 208 | self.handler = handler 209 | self.clients = [] 210 | self.sock = None 211 | self.resetHeaders() 212 | 213 | 214 | def setHeader(self, name, value): 215 | self.extraHeaders[name] = value 216 | 217 | 218 | def resetHeaders(self): 219 | self.headers = [ 220 | ['HTTP/1.1 200 OK', None], 221 | ['Content-Type', 'text/json'], 222 | ['Access-Control-Allow-Origin', '*'] 223 | ] 224 | self.extraHeaders = {} 225 | 226 | 227 | def getHeaders(self): 228 | headers = self.headers[:] 229 | for name in self.extraHeaders: 230 | headers.append([name, self.extraHeaders[name]]) 231 | return headers 232 | 233 | 234 | def advance(self): 235 | if self.sock is not None: 236 | self.acceptClients() 237 | self.advanceClients() 238 | 239 | 240 | def acceptClients(self): 241 | rlist = select.select([self.sock], [], [], 0)[0] 242 | if not rlist: 243 | return 244 | 245 | clientSock = self.sock.accept()[0] 246 | if clientSock is not None: 247 | clientSock.setblocking(False) 248 | self.clients.append(AjaxClient(clientSock, self.handlerWrapper)) 249 | 250 | 251 | def advanceClients(self): 252 | self.clients = list(filter(lambda c: c.advance(), self.clients)) 253 | 254 | 255 | def listen(self): 256 | self.close() 257 | 258 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 259 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 260 | self.sock.setblocking(False) 261 | self.sock.bind((NET_ADDRESS, NET_PORT)) 262 | self.sock.listen(NET_BACKLOG) 263 | 264 | 265 | def handlerWrapper(self, req): 266 | if len(req.body) == 0: 267 | body = makeBytes('AnkiConnect v.{}'.format(API_VERSION)) 268 | else: 269 | try: 270 | params = json.loads(makeStr(req.body)) 271 | body = makeBytes(json.dumps(self.handler(params))) 272 | except ValueError: 273 | body = makeBytes(json.dumps(None)) 274 | 275 | resp = bytes() 276 | 277 | self.setHeader('Content-Length', str(len(body))) 278 | headers = self.getHeaders() 279 | 280 | for key, value in headers: 281 | if value is None: 282 | resp += makeBytes('{}\r\n'.format(key)) 283 | else: 284 | resp += makeBytes('{}: {}\r\n'.format(key, value)) 285 | 286 | resp += makeBytes('\r\n') 287 | resp += body 288 | 289 | return resp 290 | 291 | 292 | def close(self): 293 | if self.sock is not None: 294 | self.sock.close() 295 | self.sock = None 296 | 297 | for client in self.clients: 298 | client.close() 299 | 300 | self.clients = [] 301 | 302 | 303 | # 304 | # AnkiNoteParams 305 | # 306 | 307 | class AnkiNoteParams: 308 | def __init__(self, params): 309 | self.deckName = params.get('deckName') 310 | self.modelName = params.get('modelName') 311 | self.fields = params.get('fields', {}) 312 | self.tags = params.get('tags', []) 313 | 314 | class Audio: 315 | def __init__(self, params): 316 | self.url = params.get('url') 317 | self.filename = params.get('filename') 318 | self.skipHash = params.get('skipHash') 319 | self.fields = params.get('fields', []) 320 | 321 | def validate(self): 322 | return ( 323 | verifyString(self.url) and 324 | verifyString(self.filename) and os.path.dirname(self.filename) == '' and 325 | verifyStringList(self.fields) and 326 | (verifyString(self.skipHash) or self.skipHash is None) 327 | ) 328 | 329 | audio = Audio(params.get('audio', {})) 330 | self.audio = audio if audio.validate() else None 331 | 332 | 333 | def validate(self): 334 | return ( 335 | verifyString(self.deckName) and 336 | verifyString(self.modelName) and 337 | type(self.fields) == dict and verifyStringList(list(self.fields.keys())) and verifyStringList(list(self.fields.values())) and 338 | type(self.tags) == list and verifyStringList(self.tags) 339 | ) 340 | 341 | 342 | # 343 | # AnkiBridge 344 | # 345 | 346 | class AnkiBridge: 347 | def storeMediaFile(self, filename, data): 348 | self.deleteMediaFile(filename) 349 | self.media().writeData(filename, base64.b64decode(data)) 350 | 351 | 352 | def retrieveMediaFile(self, filename): 353 | # based on writeData from anki/media.py 354 | filename = os.path.basename(filename) 355 | filename = normalize("NFC", filename) 356 | filename = self.media().stripIllegal(filename) 357 | 358 | path = os.path.join(self.media().dir(), filename) 359 | if os.path.exists(path): 360 | with open(path, 'rb') as file: 361 | return base64.b64encode(file.read()).decode('ascii') 362 | 363 | return False 364 | 365 | 366 | def deleteMediaFile(self, filename): 367 | self.media().syncDelete(filename) 368 | 369 | 370 | def addNote(self, params): 371 | collection = self.collection() 372 | if collection is None: 373 | return 374 | 375 | note = self.createNote(params) 376 | if note is None: 377 | return 378 | 379 | if params.audio is not None and len(params.audio.fields) > 0: 380 | data = download(params.audio.url) 381 | if data is not None: 382 | if params.audio.skipHash is None: 383 | skip = False 384 | else: 385 | m = hashlib.md5() 386 | m.update(data) 387 | skip = params.audio.skipHash == m.hexdigest() 388 | 389 | if not skip: 390 | audioInject(note, params.audio.fields, params.audio.filename) 391 | self.media().writeData(params.audio.filename, data) 392 | 393 | self.startEditing() 394 | collection.addNote(note) 395 | collection.autosave() 396 | self.stopEditing() 397 | 398 | return note.id 399 | 400 | 401 | def canAddNote(self, note): 402 | return bool(self.createNote(note)) 403 | 404 | 405 | def createNote(self, params): 406 | collection = self.collection() 407 | if collection is None: 408 | return 409 | 410 | model = collection.models.byName(params.modelName) 411 | if model is None: 412 | return 413 | 414 | deck = collection.decks.byName(params.deckName) 415 | if deck is None: 416 | return 417 | 418 | note = anki.notes.Note(collection, model) 419 | note.model()['did'] = deck['id'] 420 | note.tags = params.tags 421 | 422 | for name, value in params.fields.items(): 423 | if name in note: 424 | note[name] = value 425 | 426 | if not note.dupeOrEmpty(): 427 | return note 428 | 429 | def updateNoteFields(self, params): 430 | collection = self.collection() 431 | if collection is None: 432 | return 433 | 434 | note = collection.getNote(params['id']) 435 | if note is None: 436 | raise Exception("Failed to get note:{}".format(params['id'])) 437 | for name, value in params['fields'].items(): 438 | if name in note: 439 | note[name] = value 440 | note.flush() 441 | 442 | def addTags(self, notes, tags, add=True): 443 | self.startEditing() 444 | self.collection().tags.bulkAdd(notes, tags, add) 445 | self.stopEditing() 446 | 447 | 448 | def getTags(self): 449 | return self.collection().tags.all() 450 | 451 | 452 | def suspend(self, cards, suspend=True): 453 | for card in cards: 454 | isSuspended = self.isSuspended(card) 455 | if suspend and isSuspended: 456 | cards.remove(card) 457 | elif not suspend and not isSuspended: 458 | cards.remove(card) 459 | 460 | if cards: 461 | self.startEditing() 462 | if suspend: 463 | self.collection().sched.suspendCards(cards) 464 | else: 465 | self.collection().sched.unsuspendCards(cards) 466 | self.stopEditing() 467 | return True 468 | 469 | return False 470 | 471 | 472 | def areSuspended(self, cards): 473 | suspended = [] 474 | for card in cards: 475 | card = self.collection().getCard(card) 476 | if card.queue == -1: 477 | suspended.append(True) 478 | else: 479 | suspended.append(False) 480 | return suspended 481 | 482 | 483 | def areDue(self, cards): 484 | due = [] 485 | for card in cards: 486 | if self.findCards('cid:%s is:new' % card): 487 | due.append(True) 488 | continue 489 | 490 | date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] 491 | if (ivl >= -1200): 492 | if self.findCards('cid:%s is:due' % card): 493 | due.append(True) 494 | else: 495 | due.append(False) 496 | else: 497 | if date - ivl <= time(): 498 | due.append(True) 499 | else: 500 | due.append(False) 501 | 502 | return due 503 | 504 | 505 | def getIntervals(self, cards, complete=False): 506 | intervals = [] 507 | for card in cards: 508 | if self.findCards('cid:%s is:new' % card): 509 | intervals.append(0) 510 | continue 511 | 512 | interval = self.collection().db.list('select ivl from revlog where cid = ?', card) 513 | if not complete: 514 | interval = interval[-1] 515 | intervals.append(interval) 516 | return intervals 517 | 518 | 519 | def startEditing(self): 520 | self.window().requireReset() 521 | 522 | 523 | def stopEditing(self): 524 | if self.collection() is not None: 525 | self.window().maybeReset() 526 | 527 | 528 | def window(self): 529 | return aqt.mw 530 | 531 | 532 | def reviewer(self): 533 | return self.window().reviewer 534 | 535 | 536 | def collection(self): 537 | return self.window().col 538 | 539 | 540 | def scheduler(self): 541 | return self.collection().sched 542 | 543 | 544 | def multi(self, actions): 545 | response = [] 546 | for item in actions: 547 | response.append(AnkiConnect.handler(ac, item)) 548 | return response 549 | 550 | 551 | def media(self): 552 | collection = self.collection() 553 | if collection is not None: 554 | return collection.media 555 | 556 | 557 | def modelNames(self): 558 | collection = self.collection() 559 | if collection is not None: 560 | return collection.models.allNames() 561 | 562 | 563 | def modelNamesAndIds(self): 564 | models = {} 565 | 566 | modelNames = self.modelNames() 567 | for model in modelNames: 568 | mid = self.collection().models.byName(model)['id'] 569 | mid = int(mid) # sometimes Anki stores the ID as a string 570 | models[model] = mid 571 | 572 | return models 573 | 574 | 575 | def modelNameFromId(self, modelId): 576 | collection = self.collection() 577 | if collection is not None: 578 | model = collection.models.get(modelId) 579 | if model is not None: 580 | return model['name'] 581 | 582 | 583 | def modelFieldNames(self, modelName): 584 | collection = self.collection() 585 | if collection is not None: 586 | model = collection.models.byName(modelName) 587 | if model is not None: 588 | return [field['name'] for field in model['flds']] 589 | 590 | 591 | def modelFieldsOnTemplates(self, modelName): 592 | model = self.collection().models.byName(modelName) 593 | 594 | if model is not None: 595 | templates = {} 596 | for template in model['tmpls']: 597 | fields = [] 598 | 599 | for side in ['qfmt', 'afmt']: 600 | fieldsForSide = [] 601 | 602 | # based on _fieldsOnTemplate from aqt/clayout.py 603 | matches = re.findall('{{[^#/}]+?}}', template[side]) 604 | for match in matches: 605 | # remove braces and modifiers 606 | match = re.sub(r'[{}]', '', match) 607 | match = match.split(":")[-1] 608 | 609 | # for the answer side, ignore fields present on the question side + the FrontSide field 610 | if match == 'FrontSide' or side == 'afmt' and match in fields[0]: 611 | continue 612 | fieldsForSide.append(match) 613 | 614 | 615 | fields.append(fieldsForSide) 616 | 617 | templates[template['name']] = fields 618 | 619 | return templates 620 | 621 | 622 | def getDeckConfig(self, deck): 623 | if not deck in self.deckNames(): 624 | return False 625 | 626 | did = self.collection().decks.id(deck) 627 | return self.collection().decks.confForDid(did) 628 | 629 | 630 | def saveDeckConfig(self, config): 631 | configId = str(config['id']) 632 | if not configId in self.collection().decks.dconf: 633 | return False 634 | 635 | mod = anki.utils.intTime() 636 | usn = self.collection().usn() 637 | 638 | config['mod'] = mod 639 | config['usn'] = usn 640 | 641 | self.collection().decks.dconf[configId] = config 642 | self.collection().decks.changed = True 643 | return True 644 | 645 | 646 | def setDeckConfigId(self, decks, configId): 647 | for deck in decks: 648 | if not deck in self.deckNames(): 649 | return False 650 | 651 | if not str(configId) in self.collection().decks.dconf: 652 | return False 653 | 654 | for deck in decks: 655 | did = str(self.collection().decks.id(deck)) 656 | aqt.mw.col.decks.decks[did]['conf'] = configId 657 | 658 | return True 659 | 660 | 661 | def cloneDeckConfigId(self, name, cloneFrom=1): 662 | if not str(cloneFrom) in self.collection().decks.dconf: 663 | return False 664 | 665 | cloneFrom = self.collection().decks.getConf(cloneFrom) 666 | return self.collection().decks.confId(name, cloneFrom) 667 | 668 | 669 | def removeDeckConfigId(self, configId): 670 | if configId == 1 or not str(configId) in self.collection().decks.dconf: 671 | return False 672 | 673 | self.collection().decks.remConf(configId) 674 | return True 675 | 676 | 677 | def deckNames(self): 678 | collection = self.collection() 679 | if collection is not None: 680 | return collection.decks.allNames() 681 | 682 | 683 | def deckNamesAndIds(self): 684 | decks = {} 685 | 686 | deckNames = self.deckNames() 687 | for deck in deckNames: 688 | did = self.collection().decks.id(deck) 689 | decks[deck] = did 690 | 691 | return decks 692 | 693 | 694 | def deckNameFromId(self, deckId): 695 | collection = self.collection() 696 | if collection is not None: 697 | deck = collection.decks.get(deckId) 698 | if deck is not None: 699 | return deck['name'] 700 | 701 | 702 | def findNotes(self, query=None): 703 | if query is not None: 704 | return self.collection().findNotes(query) 705 | else: 706 | return [] 707 | 708 | 709 | def findCards(self, query=None): 710 | if query is not None: 711 | return self.collection().findCards(query) 712 | else: 713 | return [] 714 | 715 | def cardsInfo(self,cards): 716 | result = [] 717 | for cid in cards: 718 | try: 719 | card = self.collection().getCard(cid) 720 | model = card.model() 721 | note = card.note() 722 | fields = {} 723 | for info in model['flds']: 724 | order = info['ord'] 725 | name = info['name'] 726 | fields[name] = {'value': note.fields[order], 'order': order} 727 | 728 | result.append({ 729 | 'cardId': card.id, 730 | 'fields': fields, 731 | 'fieldOrder': card.ord, 732 | 'question': card._getQA()['q'], 733 | 'answer': card._getQA()['a'], 734 | 'modelName': model['name'], 735 | 'deckName': self.deckNameFromId(card.did), 736 | 'css': model['css'], 737 | 'factor': card.factor, 738 | #This factor is 10 times the ease percentage, 739 | # so an ease of 310% would be reported as 3100 740 | 'interval': card.ivl, 741 | 'note': card.nid 742 | }) 743 | except TypeError as e: 744 | # Anki will give a TypeError if the card ID does not exist. 745 | # Best behavior is probably to add an "empty card" to the 746 | # returned result, so that the items of the input and return 747 | # lists correspond. 748 | result.append({}) 749 | 750 | return result 751 | 752 | def notesInfo(self,notes): 753 | result = [] 754 | for nid in notes: 755 | try: 756 | note = self.collection().getNote(nid) 757 | model = note.model() 758 | 759 | fields = {} 760 | for info in model['flds']: 761 | order = info['ord'] 762 | name = info['name'] 763 | fields[name] = {'value': note.fields[order], 'order': order} 764 | 765 | result.append({ 766 | 'noteId': note.id, 767 | 'tags' : note.tags, 768 | 'fields': fields, 769 | 'modelName': model['name'], 770 | 'cards': self.collection().db.list( 771 | "select id from cards where nid = ? order by ord", note.id) 772 | }) 773 | except TypeError as e: 774 | # Anki will give a TypeError if the note ID does not exist. 775 | # Best behavior is probably to add an "empty card" to the 776 | # returned result, so that the items of the input and return 777 | # lists correspond. 778 | result.append({}) 779 | return result 780 | 781 | 782 | def getDecks(self, cards): 783 | decks = {} 784 | for card in cards: 785 | did = self.collection().db.scalar('select did from cards where id = ?', card) 786 | deck = self.collection().decks.get(did)['name'] 787 | 788 | if deck in decks: 789 | decks[deck].append(card) 790 | else: 791 | decks[deck] = [card] 792 | 793 | return decks 794 | 795 | 796 | def changeDeck(self, cards, deck): 797 | self.startEditing() 798 | 799 | did = self.collection().decks.id(deck) 800 | mod = anki.utils.intTime() 801 | usn = self.collection().usn() 802 | 803 | # normal cards 804 | scids = anki.utils.ids2str(cards) 805 | # remove any cards from filtered deck first 806 | self.collection().sched.remFromDyn(cards) 807 | 808 | # then move into new deck 809 | self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) 810 | self.stopEditing() 811 | 812 | 813 | def deleteDecks(self, decks, cardsToo=False): 814 | self.startEditing() 815 | for deck in decks: 816 | did = self.collection().decks.id(deck) 817 | self.collection().decks.rem(did, cardsToo) 818 | self.stopEditing() 819 | 820 | 821 | def cardsToNotes(self, cards): 822 | return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) 823 | 824 | 825 | def guiBrowse(self, query=None): 826 | browser = aqt.dialogs.open('Browser', self.window()) 827 | browser.activateWindow() 828 | 829 | if query is not None: 830 | browser.form.searchEdit.lineEdit().setText(query) 831 | if hasattr(browser, 'onSearch'): 832 | browser.onSearch() 833 | else: 834 | browser.onSearchActivated() 835 | 836 | return browser.model.cards 837 | 838 | 839 | def guiAddCards(self): 840 | addCards = aqt.dialogs.open('AddCards', self.window()) 841 | addCards.activateWindow() 842 | 843 | 844 | def guiReviewActive(self): 845 | return self.reviewer().card is not None and self.window().state == 'review' 846 | 847 | 848 | def guiCurrentCard(self): 849 | if not self.guiReviewActive(): 850 | return 851 | 852 | reviewer = self.reviewer() 853 | card = reviewer.card 854 | model = card.model() 855 | note = card.note() 856 | 857 | fields = {} 858 | for info in model['flds']: 859 | order = info['ord'] 860 | name = info['name'] 861 | fields[name] = {'value': note.fields[order], 'order': order} 862 | 863 | if card is not None: 864 | return { 865 | 'cardId': card.id, 866 | 'fields': fields, 867 | 'fieldOrder': card.ord, 868 | 'question': card._getQA()['q'], 869 | 'answer': card._getQA()['a'], 870 | 'buttons': [b[0] for b in reviewer._answerButtonList()], 871 | 'modelName': model['name'], 872 | 'deckName': self.deckNameFromId(card.did), 873 | 'css': model['css'] 874 | } 875 | 876 | 877 | def guiStartCardTimer(self): 878 | if not self.guiReviewActive(): 879 | return False 880 | 881 | card = self.reviewer().card 882 | 883 | if card is not None: 884 | card.startTimer() 885 | return True 886 | else: 887 | return False 888 | 889 | def guiShowQuestion(self): 890 | if self.guiReviewActive(): 891 | self.reviewer()._showQuestion() 892 | return True 893 | else: 894 | return False 895 | 896 | 897 | def guiShowAnswer(self): 898 | if self.guiReviewActive(): 899 | self.window().reviewer._showAnswer() 900 | return True 901 | else: 902 | return False 903 | 904 | 905 | def guiAnswerCard(self, ease): 906 | if not self.guiReviewActive(): 907 | return False 908 | 909 | reviewer = self.reviewer() 910 | if reviewer.state != 'answer': 911 | return False 912 | if ease <= 0 or ease > self.scheduler().answerButtons(reviewer.card): 913 | return False 914 | 915 | reviewer._answerCard(ease) 916 | return True 917 | 918 | 919 | def guiDeckOverview(self, name): 920 | collection = self.collection() 921 | if collection is not None: 922 | deck = collection.decks.byName(name) 923 | if deck is not None: 924 | collection.decks.select(deck['id']) 925 | self.window().onOverview() 926 | return True 927 | 928 | return False 929 | 930 | 931 | def guiDeckBrowser(self): 932 | self.window().moveToState('deckBrowser') 933 | 934 | 935 | def guiDeckReview(self, name): 936 | if self.guiDeckOverview(name): 937 | self.window().moveToState('review') 938 | return True 939 | else: 940 | return False 941 | 942 | def guiExitAnki(self): 943 | timer = QTimer() 944 | def exitAnki(): 945 | timer.stop() 946 | self.window().close() 947 | timer.timeout.connect(exitAnki) 948 | timer.start(1000) # 1s should be enough to allow the response to be sent. 949 | 950 | # 951 | # AnkiConnect 952 | # 953 | 954 | class AnkiConnect: 955 | def __init__(self): 956 | self.anki = AnkiBridge() 957 | self.server = AjaxServer(self.handler) 958 | 959 | try: 960 | self.server.listen() 961 | 962 | self.timer = QTimer() 963 | self.timer.timeout.connect(self.advance) 964 | self.timer.start(TICK_INTERVAL) 965 | except: 966 | QMessageBox.critical( 967 | self.anki.window(), 968 | 'AnkiConnect', 969 | 'Failed to listen on port {}.\nMake sure it is available and is not in use.'.format(NET_PORT) 970 | ) 971 | 972 | 973 | def advance(self): 974 | self.server.advance() 975 | 976 | 977 | def handler(self, request): 978 | name = request.get('action', '') 979 | version = request.get('version', 4) 980 | params = request.get('params', {}) 981 | reply = {'result': None, 'error': None} 982 | 983 | try: 984 | method = None 985 | 986 | for methodName, methodInst in inspect.getmembers(self, predicate=inspect.ismethod): 987 | apiVersionLast = 0 988 | apiNameLast = None 989 | 990 | if getattr(methodInst, 'api', False): 991 | for apiVersion, apiName in getattr(methodInst, 'versions', []): 992 | if apiVersionLast < apiVersion <= version: 993 | apiVersionLast = apiVersion 994 | apiNameLast = apiName 995 | 996 | if apiNameLast is None and apiVersionLast == 0: 997 | apiNameLast = methodName 998 | 999 | if apiNameLast is not None and apiNameLast == name: 1000 | method = methodInst 1001 | break 1002 | 1003 | if method is None: 1004 | raise Exception('unsupported action') 1005 | else: 1006 | reply['result'] = methodInst(**params) 1007 | except Exception as e: 1008 | reply['error'] = str(e) 1009 | 1010 | if version > 4: 1011 | return reply 1012 | else: 1013 | return reply['result'] 1014 | 1015 | 1016 | @webApi() 1017 | def multi(self, actions): 1018 | return self.anki.multi(actions) 1019 | 1020 | 1021 | @webApi() 1022 | def storeMediaFile(self, filename, data): 1023 | return self.anki.storeMediaFile(filename, data) 1024 | 1025 | 1026 | @webApi() 1027 | def retrieveMediaFile(self, filename): 1028 | return self.anki.retrieveMediaFile(filename) 1029 | 1030 | 1031 | @webApi() 1032 | def deleteMediaFile(self, filename): 1033 | return self.anki.deleteMediaFile(filename) 1034 | 1035 | 1036 | @webApi() 1037 | def deckNames(self): 1038 | return self.anki.deckNames() 1039 | 1040 | 1041 | @webApi() 1042 | def deckNamesAndIds(self): 1043 | return self.anki.deckNamesAndIds() 1044 | 1045 | 1046 | @webApi() 1047 | def modelNames(self): 1048 | return self.anki.modelNames() 1049 | 1050 | 1051 | @webApi() 1052 | def modelNamesAndIds(self): 1053 | return self.anki.modelNamesAndIds() 1054 | 1055 | 1056 | @webApi() 1057 | def modelFieldNames(self, modelName): 1058 | return self.anki.modelFieldNames(modelName) 1059 | 1060 | 1061 | @webApi() 1062 | def modelFieldsOnTemplates(self, modelName): 1063 | return self.anki.modelFieldsOnTemplates(modelName) 1064 | 1065 | 1066 | @webApi() 1067 | def getDeckConfig(self, deck): 1068 | return self.anki.getDeckConfig(deck) 1069 | 1070 | 1071 | @webApi() 1072 | def saveDeckConfig(self, config): 1073 | return self.anki.saveDeckConfig(config) 1074 | 1075 | 1076 | @webApi() 1077 | def setDeckConfigId(self, decks, configId): 1078 | return self.anki.setDeckConfigId(decks, configId) 1079 | 1080 | 1081 | @webApi() 1082 | def cloneDeckConfigId(self, name, cloneFrom=1): 1083 | return self.anki.cloneDeckConfigId(name, cloneFrom) 1084 | 1085 | 1086 | @webApi() 1087 | def removeDeckConfigId(self, configId): 1088 | return self.anki.removeDeckConfigId(configId) 1089 | 1090 | 1091 | @webApi() 1092 | def addNote(self, note): 1093 | params = AnkiNoteParams(note) 1094 | if params.validate(): 1095 | return self.anki.addNote(params) 1096 | 1097 | 1098 | @webApi() 1099 | def addNotes(self, notes): 1100 | results = [] 1101 | for note in notes: 1102 | params = AnkiNoteParams(note) 1103 | if params.validate(): 1104 | results.append(self.anki.addNote(params)) 1105 | else: 1106 | results.append(None) 1107 | 1108 | return results 1109 | 1110 | @webApi() 1111 | def updateNoteFields(self, note): 1112 | return self.anki.updateNoteFields(note) 1113 | 1114 | @webApi() 1115 | def canAddNotes(self, notes): 1116 | results = [] 1117 | for note in notes: 1118 | params = AnkiNoteParams(note) 1119 | results.append(params.validate() and self.anki.canAddNote(params)) 1120 | 1121 | return results 1122 | 1123 | 1124 | @webApi() 1125 | def addTags(self, notes, tags, add=True): 1126 | return self.anki.addTags(notes, tags, add) 1127 | 1128 | 1129 | @webApi() 1130 | def removeTags(self, notes, tags): 1131 | return self.anki.addTags(notes, tags, False) 1132 | 1133 | 1134 | @webApi() 1135 | def getTags(self): 1136 | return self.anki.getTags() 1137 | 1138 | 1139 | @webApi() 1140 | def suspend(self, cards, suspend=True): 1141 | return self.anki.suspend(cards, suspend) 1142 | 1143 | 1144 | @webApi() 1145 | def unsuspend(self, cards): 1146 | return self.anki.suspend(cards, False) 1147 | 1148 | 1149 | @webApi() 1150 | def areSuspended(self, cards): 1151 | return self.anki.areSuspended(cards) 1152 | 1153 | 1154 | @webApi() 1155 | def areDue(self, cards): 1156 | return self.anki.areDue(cards) 1157 | 1158 | 1159 | @webApi() 1160 | def getIntervals(self, cards, complete=False): 1161 | return self.anki.getIntervals(cards, complete) 1162 | 1163 | 1164 | @webApi() 1165 | def upgrade(self): 1166 | response = QMessageBox.question( 1167 | self.anki.window(), 1168 | 'AnkiConnect', 1169 | 'Upgrade to the latest version?', 1170 | QMessageBox.Yes | QMessageBox.No 1171 | ) 1172 | 1173 | if response == QMessageBox.Yes: 1174 | data = download(URL_UPGRADE) 1175 | if data is None: 1176 | QMessageBox.critical(self.anki.window(), 'AnkiConnect', 'Failed to download latest version.') 1177 | else: 1178 | path = os.path.splitext(__file__)[0] + '.py' 1179 | with open(path, 'w') as fp: 1180 | fp.write(makeStr(data)) 1181 | QMessageBox.information(self.anki.window(), 'AnkiConnect', 'Upgraded to the latest version, please restart Anki.') 1182 | return True 1183 | 1184 | return False 1185 | 1186 | 1187 | @webApi() 1188 | def version(self): 1189 | return API_VERSION 1190 | 1191 | 1192 | @webApi() 1193 | def findNotes(self, query=None): 1194 | return self.anki.findNotes(query) 1195 | 1196 | 1197 | @webApi() 1198 | def findCards(self, query=None): 1199 | return self.anki.findCards(query) 1200 | 1201 | 1202 | @webApi() 1203 | def getDecks(self, cards): 1204 | return self.anki.getDecks(cards) 1205 | 1206 | 1207 | @webApi() 1208 | def changeDeck(self, cards, deck): 1209 | return self.anki.changeDeck(cards, deck) 1210 | 1211 | 1212 | @webApi() 1213 | def deleteDecks(self, decks, cardsToo=False): 1214 | return self.anki.deleteDecks(decks, cardsToo) 1215 | 1216 | 1217 | @webApi() 1218 | def cardsToNotes(self, cards): 1219 | return self.anki.cardsToNotes(cards) 1220 | 1221 | 1222 | @webApi() 1223 | def guiBrowse(self, query=None): 1224 | return self.anki.guiBrowse(query) 1225 | 1226 | 1227 | @webApi() 1228 | def guiAddCards(self): 1229 | return self.anki.guiAddCards() 1230 | 1231 | 1232 | @webApi() 1233 | def guiCurrentCard(self): 1234 | return self.anki.guiCurrentCard() 1235 | 1236 | 1237 | @webApi() 1238 | def guiStartCardTimer(self): 1239 | return self.anki.guiStartCardTimer() 1240 | 1241 | 1242 | @webApi() 1243 | def guiAnswerCard(self, ease): 1244 | return self.anki.guiAnswerCard(ease) 1245 | 1246 | 1247 | @webApi() 1248 | def guiShowQuestion(self): 1249 | return self.anki.guiShowQuestion() 1250 | 1251 | 1252 | @webApi() 1253 | def guiShowAnswer(self): 1254 | return self.anki.guiShowAnswer() 1255 | 1256 | 1257 | @webApi() 1258 | def guiDeckOverview(self, name): 1259 | return self.anki.guiDeckOverview(name) 1260 | 1261 | 1262 | @webApi() 1263 | def guiDeckBrowser(self): 1264 | return self.anki.guiDeckBrowser() 1265 | 1266 | 1267 | @webApi() 1268 | def guiDeckReview(self, name): 1269 | return self.anki.guiDeckReview(name) 1270 | 1271 | 1272 | @webApi() 1273 | def guiExitAnki(self): 1274 | return self.anki.guiExitAnki() 1275 | 1276 | @webApi() 1277 | def cardsInfo(self, cards): 1278 | return self.anki.cardsInfo(cards) 1279 | 1280 | @webApi() 1281 | def notesInfo(self, notes): 1282 | return self.anki.notesInfo(notes) 1283 | 1284 | # 1285 | # Entry 1286 | # 1287 | 1288 | ac = AnkiConnect() 1289 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AnkiConnect # 2 | 3 | The AnkiConnect plugin enables external applications such as [Yomichan](https://foosoft.net/projects/yomichan/) to communicate with 4 | [Anki](https://apps.ankiweb.net/) over a network interface. This software makes it possible to execute queries against 5 | the user's card deck, automatically create new vocabulary and Kanji flash cards, and more. AnkiConnect is compatible 6 | with the latest stable (2.0.x) and alpha (2.1.x) releases of Anki and works on Linux, Windows, and Mac OS X. 7 | 8 | ## Table of Contents ## 9 | 10 | * [Installation](https://foosoft.net/projects/anki-connect/#installation) 11 | * [Notes for Windows Users](https://foosoft.net/projects/anki-connect/#notes-for-windows-users) 12 | * [Notes for Mac OS X Users](https://foosoft.net/projects/anki-connect/#notes-for-mac-os-x-users) 13 | * [Application Interface for Developers](https://foosoft.net/projects/anki-connect/#application-interface-for-developers) 14 | * [Sample Invocation](https://foosoft.net/projects/anki-connect/#sample-invocation) 15 | * [Supported Actions](https://foosoft.net/projects/anki-connect/#supported-actions) 16 | * [Miscellaneous](https://foosoft.net/projects/anki-connect/#miscellaneous) 17 | * [Decks](https://foosoft.net/projects/anki-connect/#decks) 18 | * [Models](https://foosoft.net/projects/anki-connect/#models) 19 | * [Notes](https://foosoft.net/projects/anki-connect/#notes) 20 | * [Cards](https://foosoft.net/projects/anki-connect/#cards) 21 | * [Media](https://foosoft.net/projects/anki-connect/#media) 22 | * [Graphical](https://foosoft.net/projects/anki-connect/#graphical) 23 | * [License](https://foosoft.net/projects/anki-connect/#license) 24 | 25 | ## Installation ## 26 | 27 | The installation process is similar to that of other Anki plugins and can be accomplished in three steps: 28 | 29 | 1. Open the *Install Add-on* dialog by selecting *Tools* > *Add-ons* > *Browse & Install* in Anki. 30 | 2. Input *[2055492159](https://ankiweb.net/shared/info/2055492159)* into the text box labeled *Code* and press the *OK* button to proceed. 31 | 3. Restart Anki when prompted to do so in order to complete the installation of AnkiConnect. 32 | 33 | Anki must be kept running in the background in order for other applications to be able to use AnkiConnect. You can 34 | verify that AnkiConnect is running at any time by accessing [localhost:8765](http://localhost:8765) in your browser. If 35 | the server is running, you should see the message *AnkiConnect v.5* displayed in your browser window. 36 | 37 | ### Notes for Windows Users ### 38 | 39 | Windows users may see a firewall nag dialog box appear on Anki startup. This occurs because AnkiConnect hosts a local 40 | server in order to enable other applications to connect to it. The host application, Anki, must be unblocked for this 41 | plugin to function correctly. 42 | 43 | ### Notes for Mac OS X Users ### 44 | 45 | Starting with [Mac OS X Mavericks](https://en.wikipedia.org/wiki/OS_X_Mavericks), a feature named *App Nap* has been 46 | introduced to the operating system. This feature causes certain applications which are open (but not visible) to be 47 | placed in a suspended state. As this behavior causes AnkiConnect to stop working while you have another window in the 48 | foreground, App Nap should be disabled for Anki: 49 | 50 | 1. Start the Terminal application. 51 | 2. Execute the following command in the terminal window: 52 | 53 | ``` 54 | defaults write net.ichi2.anki NSAppSleepDisabled -bool true 55 | ``` 56 | 3. Restart Anki. 57 | 58 | ## Application Interface for Developers ## 59 | 60 | AnkiConnect exposes Anki features to external applications via an easy to use 61 | [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) API. After it is installed, this plugin will 62 | initialize a minimal HTTP sever running on port 8765 every time Anki executes. Other applications (including browser 63 | extensions) can then communicate with it via HTTP POST requests. 64 | 65 | 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 66 | access it from the same host on which it is running. If you need to access it over a network, you can set the 67 | environment variable `ANKICONNECT_BIND_ADDRESS` to change the binding address. For example, you can set it to `0.0.0.0` 68 | in order to bind it to all network interfaces on your host. 69 | 70 | ### Sample Invocation ### 71 | 72 | Every request consists of a JSON-encoded object containing an `action`, a `version`, and a set of contextual `params`. A 73 | simple example of a modern JavaScript application communicating with the extension is illustrated below: 74 | 75 | ```javascript 76 | function ankiConnectInvoke(action, version, params={}) { 77 | return new Promise((resolve, reject) => { 78 | const xhr = new XMLHttpRequest(); 79 | xhr.addEventListener('error', () => reject('failed to connect to AnkiConnect')); 80 | xhr.addEventListener('load', () => { 81 | try { 82 | const response = JSON.parse(xhr.responseText); 83 | if (response.error) { 84 | throw response.error; 85 | } else { 86 | if (response.hasOwnProperty('result')) { 87 | resolve(response.result); 88 | } else { 89 | reject('failed to get results from AnkiConnect'); 90 | } 91 | } 92 | } catch (e) { 93 | reject(e); 94 | } 95 | }); 96 | 97 | xhr.open('POST', 'http://127.0.0.1:8765'); 98 | xhr.send(JSON.stringify({action, version, params})); 99 | }); 100 | } 101 | 102 | try { 103 | const result = await ankiConnectInvoke('deckNames', 5); 104 | console.log(`got list of decks: ${result}`); 105 | } catch (e) { 106 | console.log(`error getting decks: ${e}`); 107 | } 108 | ``` 109 | 110 | Or using [`curl`](https://curl.haxx.se) from the command line: 111 | 112 | ```bash 113 | curl localhost:8765 -X POST -d "{\"action\": \"deckNames\", \"version\": 5}" 114 | ``` 115 | 116 | AnkiConnect will respond with an object containing two fields: `result` and `error`. The `result` field contains the 117 | return value of the executed API, and the `error` field is a description of any exception thrown during API execution 118 | (the value `null` is used if execution completed successfully). 119 | 120 | *Sample successful response*: 121 | ```json 122 | {"result": ["Default", "Filtered Deck 1"], "error": null} 123 | ``` 124 | 125 | *Samples of failed responses*: 126 | ```json 127 | {"result": null, "error": "unsupported action"} 128 | ``` 129 | ```json 130 | {"result": null, "error": "guiBrowse() got an unexpected keyword argument 'foobar'"} 131 | ``` 132 | 133 | For compatibility with clients designed to work with older versions of AnkiConnect, failing to provide a `version` field 134 | in the request will make the version default to 4. Furthermore, when the provided version is level 4 or below, the API 135 | response will only contain the value of the `result`; no `error` field is available for error handling. 136 | 137 | ### Supported Actions ### 138 | 139 | Below is a comprehensive list of currently supported actions. Note that deprecated APIs will continue to function 140 | despite not being listed on this page as long as your request is labeled with a version number corresponding to when the 141 | API was available for use. 142 | 143 | This page currently documents **version 5** of the API. Make sure to include this version number in your requests to 144 | guarantee that your application continues to function properly in the future. 145 | 146 | #### Miscellaneous #### 147 | 148 | * **version** 149 | 150 | Gets the version of the API exposed by this plugin. Currently versions `1` through `5` are defined. 151 | 152 | This should be the first call you make to make sure that your application and AnkiConnect are able to communicate 153 | properly with each other. New versions of AnkiConnect are backwards compatible; as long as you are using actions 154 | which are available in the reported AnkiConnect version or earlier, everything should work fine. 155 | 156 | *Sample request*: 157 | ```json 158 | { 159 | "action": "version", 160 | "version": 5 161 | } 162 | ``` 163 | 164 | *Sample result*: 165 | ```json 166 | { 167 | "result": 5, 168 | "error": null 169 | } 170 | ``` 171 | 172 | * **upgrade** 173 | 174 | Displays a confirmation dialog box in Anki asking the user if they wish to upgrade AnkiConnect to the latest version 175 | from the project's [master branch](https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py) on 176 | GitHub. Returns a boolean value indicating if the plugin was upgraded or not. 177 | 178 | *Sample request*: 179 | ```json 180 | { 181 | "action": "upgrade", 182 | "version": 5 183 | } 184 | ``` 185 | 186 | *Sample result*: 187 | ```json 188 | { 189 | "result": true, 190 | "error": null 191 | } 192 | ``` 193 | 194 | * **multi** 195 | 196 | Performs multiple actions in one request, returning an array with the response of each action (in the given order). 197 | 198 | *Sample request*: 199 | ```json 200 | { 201 | "action": "multi", 202 | "version": 5, 203 | "params": { 204 | "actions": [ 205 | {"action": "deckNames"}, 206 | { 207 | "action": "browse", 208 | "params": {"query": "deck:current"} 209 | } 210 | ] 211 | } 212 | } 213 | ``` 214 | 215 | *Sample result*: 216 | ```json 217 | { 218 | "result": [ 219 | ["Default"], 220 | [1494723142483, 1494703460437, 1494703479525] 221 | ], 222 | "error": null 223 | } 224 | ``` 225 | 226 | #### Decks #### 227 | 228 | * **deckNames** 229 | 230 | Gets the complete list of deck names for the current user. 231 | 232 | *Sample request*: 233 | ```json 234 | { 235 | "action": "deckNames", 236 | "version": 5 237 | } 238 | ``` 239 | 240 | *Sample result*: 241 | ```json 242 | { 243 | "result": ["Default"], 244 | "error": null 245 | } 246 | ``` 247 | 248 | * **deckNamesAndIds** 249 | 250 | Gets the complete list of deck names and their respective IDs for the current user. 251 | 252 | *Sample request*: 253 | ```json 254 | { 255 | "action": "deckNamesAndIds", 256 | "version": 5 257 | } 258 | ``` 259 | 260 | *Sample result*: 261 | ```json 262 | { 263 | "result": {"Default": 1}, 264 | "error": null 265 | } 266 | ``` 267 | 268 | * **getDecks** 269 | 270 | 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 271 | cards which belong to it. 272 | 273 | *Sample request*: 274 | ```json 275 | { 276 | "action": "getDecks", 277 | "version": 5, 278 | "params": { 279 | "cards": [1502298036657, 1502298033753, 1502032366472] 280 | } 281 | } 282 | ``` 283 | 284 | *Sample result*: 285 | ```json 286 | { 287 | "result": { 288 | "Default": [1502032366472], 289 | "Japanese::JLPT N3": [1502298036657, 1502298033753] 290 | }, 291 | "error": null 292 | } 293 | ``` 294 | 295 | * **changeDeck** 296 | 297 | Moves cards with the given IDs to a different deck, creating the deck if it doesn't exist yet. 298 | 299 | *Sample request*: 300 | ```json 301 | { 302 | "action": "changeDeck", 303 | "version": 5, 304 | "params": { 305 | "cards": [1502098034045, 1502098034048, 1502298033753], 306 | "deck": "Japanese::JLPT N3" 307 | } 308 | } 309 | ``` 310 | 311 | *Sample result*: 312 | ```json 313 | { 314 | "result": null, 315 | "error": null 316 | } 317 | ``` 318 | 319 | * **deleteDecks** 320 | 321 | Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within 322 | the deleted decks will also be deleted; otherwise they will be moved to the default deck. 323 | 324 | *Sample request*: 325 | ```json 326 | { 327 | "action": "deleteDecks", 328 | "version": 5, 329 | "params": { 330 | "decks": ["Japanese::JLPT N5", "Easy Spanish"], 331 | "cardsToo": true 332 | } 333 | } 334 | ``` 335 | 336 | *Sample result*: 337 | ```json 338 | { 339 | "result": null, 340 | "error": null 341 | } 342 | ``` 343 | 344 | * **getDeckConfig** 345 | 346 | Gets the configuration group object for the given deck. 347 | 348 | *Sample request*: 349 | ```json 350 | { 351 | "action": "getDeckConfig", 352 | "version": 5, 353 | "params": { 354 | "deck": "Default" 355 | } 356 | } 357 | ``` 358 | 359 | *Sample result*: 360 | ```json 361 | { 362 | "result": { 363 | "lapse": { 364 | "leechFails": 8, 365 | "delays": [10], 366 | "minInt": 1, 367 | "leechAction": 0, 368 | "mult": 0 369 | }, 370 | "dyn": false, 371 | "autoplay": true, 372 | "mod": 1502970872, 373 | "id": 1, 374 | "maxTaken": 60, 375 | "new": { 376 | "bury": true, 377 | "order": 1, 378 | "initialFactor": 2500, 379 | "perDay": 20, 380 | "delays": [1, 10], 381 | "separate": true, 382 | "ints": [1, 4, 7] 383 | }, 384 | "name": "Default", 385 | "rev": { 386 | "bury": true, 387 | "ivlFct": 1, 388 | "ease4": 1.3, 389 | "maxIvl": 36500, 390 | "perDay": 100, 391 | "minSpace": 1, 392 | "fuzz": 0.05 393 | }, 394 | "timer": 0, 395 | "replayq": true, 396 | "usn": -1 397 | }, 398 | "error": null 399 | } 400 | ``` 401 | 402 | * **saveDeckConfig** 403 | 404 | Saves the given configuration group, returning `true` on success or `false` if the ID of the configuration group is 405 | invalid (such as when it does not exist). 406 | 407 | *Sample request*: 408 | ```json 409 | { 410 | "action": "saveDeckConfig", 411 | "version": 5, 412 | "params": { 413 | "config": { 414 | "lapse": { 415 | "leechFails": 8, 416 | "delays": [10], 417 | "minInt": 1, 418 | "leechAction": 0, 419 | "mult": 0 420 | }, 421 | "dyn": false, 422 | "autoplay": true, 423 | "mod": 1502970872, 424 | "id": 1, 425 | "maxTaken": 60, 426 | "new": { 427 | "bury": true, 428 | "order": 1, 429 | "initialFactor": 2500, 430 | "perDay": 20, 431 | "delays": [1, 10], 432 | "separate": true, 433 | "ints": [1, 4, 7] 434 | }, 435 | "name": "Default", 436 | "rev": { 437 | "bury": true, 438 | "ivlFct": 1, 439 | "ease4": 1.3, 440 | "maxIvl": 36500, 441 | "perDay": 100, 442 | "minSpace": 1, 443 | "fuzz": 0.05 444 | }, 445 | "timer": 0, 446 | "replayq": true, 447 | "usn": -1 448 | } 449 | } 450 | } 451 | ``` 452 | 453 | *Sample result*: 454 | ```json 455 | { 456 | "result": true, 457 | "error": null 458 | } 459 | ``` 460 | 461 | * **setDeckConfigId** 462 | 463 | Changes the configuration group for the given decks to the one with the given ID. Returns `true` on success or 464 | `false` if the given configuration group or any of the given decks do not exist. 465 | 466 | *Sample request*: 467 | ```json 468 | { 469 | "action": "setDeckConfigId", 470 | "version": 5, 471 | "params": { 472 | "decks": ["Default"], 473 | "configId": 1 474 | } 475 | } 476 | ``` 477 | 478 | *Sample result*: 479 | ```json 480 | { 481 | "result": true, 482 | "error": null 483 | } 484 | ``` 485 | 486 | * **cloneDeckConfigId** 487 | 488 | Creates a new configuration group with the given name, cloning from the group with the given ID, or from the default 489 | group if this is unspecified. Returns the ID of the new configuration group, or `false` if the specified group to 490 | clone from does not exist. 491 | 492 | *Sample request*: 493 | ```json 494 | { 495 | "action": "cloneDeckConfigId", 496 | "version": 5, 497 | "params": { 498 | "name": "Copy of Default", 499 | "cloneFrom": 1 500 | } 501 | } 502 | ``` 503 | 504 | *Sample result*: 505 | ```json 506 | { 507 | "result": 1502972374573, 508 | "error": null 509 | } 510 | ``` 511 | 512 | * **removeDeckConfigId** 513 | 514 | Removes the configuration group with the given ID, returning `true` if successful, or `false` if attempting to 515 | remove either the default configuration group (ID = 1) or a configuration group that does not exist. 516 | 517 | *Sample request*: 518 | ```json 519 | { 520 | "action": "removeDeckConfigId", 521 | "version": 5, 522 | "params": { 523 | "configId": 1502972374573 524 | } 525 | } 526 | ``` 527 | 528 | *Sample result*: 529 | ```json 530 | { 531 | "result": true, 532 | "error": null 533 | } 534 | ``` 535 | 536 | #### Models #### 537 | 538 | * **modelNames** 539 | 540 | Gets the complete list of model names for the current user. 541 | 542 | *Sample request*: 543 | ```json 544 | { 545 | "action": "modelNames", 546 | "version": 5 547 | } 548 | ``` 549 | 550 | *Sample result*: 551 | ```json 552 | { 553 | "result": ["Basic", "Basic (and reversed card)"], 554 | "error": null 555 | } 556 | ``` 557 | 558 | * **modelNamesAndIds** 559 | 560 | Gets the complete list of model names and their corresponding IDs for the current user. 561 | 562 | *Sample request*: 563 | ```json 564 | { 565 | "action": "modelNamesAndIds", 566 | "version": 5 567 | } 568 | ``` 569 | 570 | *Sample result*: 571 | ```json 572 | { 573 | "result": { 574 | "Basic": 1483883011648, 575 | "Basic (and reversed card)": 1483883011644, 576 | "Basic (optional reversed card)": 1483883011631, 577 | "Cloze": 1483883011630 578 | }, 579 | "error": null 580 | } 581 | ``` 582 | 583 | * **modelFieldNames** 584 | 585 | Gets the complete list of field names for the provided model name. 586 | 587 | *Sample request*: 588 | ```json 589 | { 590 | "action": "modelFieldNames", 591 | "version": 5, 592 | "params": { 593 | "modelName": "Basic" 594 | } 595 | } 596 | ``` 597 | 598 | *Sample result*: 599 | ```json 600 | { 601 | "result": ["Front", "Back"], 602 | "error": null 603 | } 604 | ``` 605 | 606 | * **modelFieldsOnTemplates** 607 | 608 | Returns an object indicating the fields on the question and answer side of each card template for the given model 609 | name. The question side is given first in each array. 610 | 611 | *Sample request*: 612 | ```json 613 | { 614 | "action": "modelFieldsOnTemplates", 615 | "version": 5, 616 | "params": { 617 | "modelName": "Basic (and reversed card)" 618 | } 619 | } 620 | ``` 621 | 622 | *Sample result*: 623 | ```json 624 | { 625 | "result": { 626 | "Card 1": [["Front"], ["Back"]], 627 | "Card 2": [["Back"], ["Front"]] 628 | }, 629 | "error": null 630 | } 631 | ``` 632 | 633 | #### Notes #### 634 | 635 | * **addNote** 636 | 637 | Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of 638 | the created note created on success, and `null` on failure. 639 | 640 | AnkiConnect can download audio files and embed them in newly created notes. The corresponding `audio` note member is 641 | optional and can be omitted. If you choose to include it, the `url` and `filename` fields must be also defined. The 642 | `skipHash` field can be optionally provided to skip the inclusion of downloaded files with an MD5 hash that matches 643 | the provided value. This is useful for avoiding the saving of error pages and stub files. The `fields` member is a 644 | list of fields that should play audio when the card is displayed in Anki. 645 | 646 | *Sample request*: 647 | ```json 648 | { 649 | "action": "addNote", 650 | "version": 5, 651 | "params": { 652 | "note": { 653 | "deckName": "Default", 654 | "modelName": "Basic", 655 | "fields": { 656 | "Front": "front content", 657 | "Back": "back content" 658 | }, 659 | "tags": [ 660 | "yomichan" 661 | ], 662 | "audio": { 663 | "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", 664 | "filename": "yomichan_ねこ_猫.mp3", 665 | "skipHash": "7e2c2f954ef6051373ba916f000168dc", 666 | "fields": "Front" 667 | } 668 | } 669 | } 670 | } 671 | ``` 672 | 673 | *Sample result*: 674 | ```json 675 | { 676 | "result": 1496198395707, 677 | "error": null 678 | } 679 | ``` 680 | 681 | * **addNotes** 682 | 683 | Creates multiple notes using the given deck and model, with the provided field values and tags. Returns an array of 684 | identifiers of the created notes (notes that could not be created will have a `null` identifier). Please see the 685 | documentation for `addNote` for an explanation of objects in the `notes` array. 686 | 687 | *Sample request*: 688 | ```json 689 | { 690 | "action": "addNotes", 691 | "version": 5, 692 | "params": { 693 | "notes": [ 694 | { 695 | "deckName": "Default", 696 | "modelName": "Basic", 697 | "fields": { 698 | "Front": "front content", 699 | "Back": "back content" 700 | }, 701 | "tags": [ 702 | "yomichan" 703 | ], 704 | "audio": { 705 | "url": "https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?kanji=猫&kana=ねこ", 706 | "filename": "yomichan_ねこ_猫.mp3", 707 | "skipHash": "7e2c2f954ef6051373ba916f000168dc", 708 | "fields": "Front" 709 | } 710 | } 711 | ] 712 | } 713 | } 714 | ``` 715 | 716 | *Sample result*: 717 | ```json 718 | { 719 | "result": [1496198395707, null], 720 | "error": null 721 | } 722 | ``` 723 | 724 | * **canAddNotes** 725 | 726 | Accepts an array of objects which define parameters for candidate notes (see `addNote`) and returns an array of 727 | booleans indicating whether or not the parameters at the corresponding index could be used to create a new note. 728 | 729 | *Sample request*: 730 | ```json 731 | { 732 | "action": "canAddNotes", 733 | "version": 5, 734 | "params": { 735 | "notes": [ 736 | { 737 | "deckName": "Default", 738 | "modelName": "Basic", 739 | "fields": { 740 | "Front": "front content", 741 | "Back": "back content" 742 | }, 743 | "tags": [ 744 | "yomichan" 745 | ] 746 | } 747 | ] 748 | } 749 | } 750 | ``` 751 | 752 | *Sample result*: 753 | ```json 754 | { 755 | "result": [true], 756 | "error": null 757 | } 758 | ``` 759 | 760 | * **updateNoteFields** 761 | 762 | Modify the fields of an exist note. 763 | 764 | *Sample request*: 765 | ```json 766 | { 767 | "action": "updateNoteFields", 768 | "version": 5, 769 | "params": { 770 | "note": { 771 | "id": 1514547547030, 772 | "fields": { 773 | "Front": "new front content", 774 | "Back": "new back content" 775 | } 776 | } 777 | } 778 | } 779 | ``` 780 | 781 | *Sample result*: 782 | ```json 783 | { 784 | "result": null, 785 | "error": null 786 | } 787 | ``` 788 | 789 | * **addTags** 790 | 791 | Adds tags to notes by note ID. 792 | 793 | *Sample request*: 794 | ```json 795 | { 796 | "action": "addTags", 797 | "version": 5, 798 | "params": { 799 | "notes": [1483959289817, 1483959291695], 800 | "tags": "european-languages" 801 | } 802 | } 803 | ``` 804 | 805 | *Sample result*: 806 | ```json 807 | { 808 | "result": null, 809 | "error": null 810 | } 811 | ``` 812 | 813 | * **removeTags** 814 | 815 | Remove tags from notes by note ID. 816 | 817 | *Sample request*: 818 | ```json 819 | { 820 | "action": "removeTags", 821 | "version": 5, 822 | "params": { 823 | "notes": [1483959289817, 1483959291695], 824 | "tags": "european-languages" 825 | } 826 | } 827 | ``` 828 | 829 | *Sample result*: 830 | ```json 831 | { 832 | "result": null, 833 | "error": null 834 | } 835 | ``` 836 | 837 | * **getTags** 838 | 839 | Gets the complete list of tags for the current user. 840 | 841 | *Sample request*: 842 | ```json 843 | { 844 | "action": "getTags", 845 | "version": 5 846 | } 847 | ``` 848 | 849 | *Sample result*: 850 | ```json 851 | { 852 | "result": ["european-languages", "idioms"], 853 | "error": null 854 | } 855 | ``` 856 | 857 | * **findNotes** 858 | 859 | Returns an array of note IDs for a given query. Same query syntax as `guiBrowse`. 860 | 861 | *Sample request*: 862 | ```json 863 | { 864 | "action": "findNotes", 865 | "version": 5, 866 | "params": { 867 | "query": "deck:current" 868 | } 869 | } 870 | ``` 871 | 872 | *Sample result*: 873 | ```json 874 | { 875 | "result": [1483959289817, 1483959291695], 876 | "error": null 877 | } 878 | ``` 879 | 880 | * **notesInfo** 881 | 882 | Returns a list of objects containing for each note ID the note fields, tags, note type and the cards belonging to 883 | the note. 884 | 885 | *Sample request*: 886 | ```json 887 | { 888 | "action": "notesInfo", 889 | "version": 5, 890 | "params": { 891 | "notes": [1502298033753] 892 | } 893 | } 894 | ``` 895 | 896 | *Sample result*: 897 | ```json 898 | { 899 | "result": [ 900 | { 901 | "noteId":1502298033753, 902 | "modelName": "Basic", 903 | "tags":["tag","another_tag"], 904 | "fields": { 905 | "Front": {"value": "front content", "order": 0}, 906 | "Back": {"value": "back content", "order": 1} 907 | } 908 | } 909 | ], 910 | "error": null 911 | } 912 | ``` 913 | 914 | 915 | #### Cards #### 916 | 917 | * **suspend** 918 | 919 | Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` 920 | otherwise. 921 | 922 | *Sample request*: 923 | ```json 924 | { 925 | "action": "suspend", 926 | "version": 5, 927 | "params": { 928 | "cards": [1483959291685, 1483959293217] 929 | } 930 | } 931 | ``` 932 | 933 | *Sample result*: 934 | ```json 935 | { 936 | "result": true, 937 | "error": null 938 | } 939 | ``` 940 | 941 | * **unsuspend** 942 | 943 | Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false` 944 | otherwise. 945 | 946 | *Sample request*: 947 | ```json 948 | { 949 | "action": "unsuspend", 950 | "version": 5, 951 | "params": { 952 | "cards": [1483959291685, 1483959293217] 953 | } 954 | } 955 | ``` 956 | 957 | *Sample result*: 958 | ```json 959 | { 960 | "result": true, 961 | "error": null 962 | } 963 | ``` 964 | 965 | * **areSuspended** 966 | 967 | Returns an array indicating whether each of the given cards is suspended (in the same order). 968 | 969 | *Sample request*: 970 | ```json 971 | { 972 | "action": "areSuspended", 973 | "version": 5, 974 | "params": { 975 | "cards": [1483959291685, 1483959293217] 976 | } 977 | } 978 | ``` 979 | 980 | *Sample result*: 981 | ```json 982 | { 983 | "result": [false, true], 984 | "error": null 985 | } 986 | ``` 987 | 988 | * **areDue** 989 | 990 | Returns an array indicating whether each of the given cards is due (in the same order). *Note*: cards in the 991 | learning queue with a large interval (over 20 minutes) are treated as not due until the time of their interval has 992 | passed, to match the way Anki treats them when reviewing. 993 | 994 | *Sample request*: 995 | ```json 996 | { 997 | "action": "areDue", 998 | "version": 5, 999 | "params": { 1000 | "cards": [1483959291685, 1483959293217] 1001 | } 1002 | } 1003 | ``` 1004 | 1005 | *Sample result*: 1006 | ```json 1007 | { 1008 | "result": [false, true], 1009 | "error": null 1010 | } 1011 | ``` 1012 | 1013 | * **getIntervals** 1014 | 1015 | Returns an array of the most recent intervals for each given card ID, or a 2-dimensional array of all the intervals 1016 | for each given card ID when `complete` is `true`. Negative intervals are in seconds and positive intervals in days. 1017 | 1018 | *Sample request 1*: 1019 | ```json 1020 | { 1021 | "action": "getIntervals", 1022 | "version": 5, 1023 | "params": { 1024 | "cards": [1502298033753, 1502298036657] 1025 | } 1026 | } 1027 | ``` 1028 | 1029 | *Sample result 1*: 1030 | ```json 1031 | { 1032 | "result": [-14400, 3], 1033 | "error": null 1034 | } 1035 | ``` 1036 | 1037 | *Sample request 2*: 1038 | ```json 1039 | { 1040 | "action": "getIntervals", 1041 | "version": 5, 1042 | "params": { 1043 | "cards": [1502298033753, 1502298036657], 1044 | "complete": true 1045 | } 1046 | } 1047 | ``` 1048 | 1049 | *Sample result 2*: 1050 | ```json 1051 | { 1052 | "result": [ 1053 | [-120, -180, -240, -300, -360, -14400], 1054 | [-120, -180, -240, -300, -360, -14400, 1, 3] 1055 | ], 1056 | "error": null 1057 | } 1058 | ``` 1059 | 1060 | * **findCards** 1061 | 1062 | Returns an array of card IDs for a given query. Functionally identical to `guiBrowse` but doesn't use the GUI for 1063 | better performance. 1064 | 1065 | *Sample request*: 1066 | ```json 1067 | { 1068 | "action": "findCards", 1069 | "version": 5, 1070 | "params": { 1071 | "query": "deck:current" 1072 | } 1073 | } 1074 | ``` 1075 | 1076 | *Sample result*: 1077 | ```json 1078 | { 1079 | "result": [1494723142483, 1494703460437, 1494703479525], 1080 | "error": null 1081 | } 1082 | ``` 1083 | 1084 | * **cardsToNotes** 1085 | 1086 | Returns an unordered array of note IDs for the given card IDs. For cards with the same note, the ID is only given 1087 | once in the array. 1088 | 1089 | *Sample request*: 1090 | ```json 1091 | { 1092 | "action": "cardsToNotes", 1093 | "version": 5, 1094 | "params": { 1095 | "cards": [1502098034045, 1502098034048, 1502298033753] 1096 | } 1097 | } 1098 | ``` 1099 | 1100 | *Sample result*: 1101 | ```json 1102 | { 1103 | "result": [1502098029797, 1502298025183], 1104 | "error": null 1105 | } 1106 | ``` 1107 | 1108 | * **cardsInfo** 1109 | 1110 | Returns a list of objects containing for each card ID the card fields, front and back sides including CSS, note 1111 | type, the note that the card belongs to, and deck name, as well as ease and interval. 1112 | 1113 | *Sample request*: 1114 | ```json 1115 | { 1116 | "action": "cardsInfo", 1117 | "version": 5, 1118 | "params": { 1119 | "cards": [1498938915662, 1502098034048] 1120 | } 1121 | } 1122 | ``` 1123 | 1124 | *Sample result*: 1125 | ```json 1126 | { 1127 | "result": [ 1128 | { 1129 | "answer": "back content", 1130 | "question": "front content", 1131 | "deckName": "Default", 1132 | "modelName": "Basic", 1133 | "fieldOrder": 1, 1134 | "fields": { 1135 | "Front": {"value": "front content", "order": 0}, 1136 | "Back": {"value": "back content", "order": 1} 1137 | }, 1138 | "css":"p {font-family:Arial;}", 1139 | "cardId": 1498938915662, 1140 | "interval": 16, 1141 | "note":1502298033753 1142 | }, 1143 | { 1144 | "answer": "back content", 1145 | "question": "front content", 1146 | "deckName": "Default", 1147 | "modelName": "Basic", 1148 | "fieldOrder": 0, 1149 | "fields": { 1150 | "Front": {"value": "front content", "order": 0}, 1151 | "Back": {"value": "back content", "order": 1} 1152 | }, 1153 | "css":"p {font-family:Arial;}", 1154 | "cardId": 1502098034048, 1155 | "interval": 23, 1156 | "note":1502298033753 1157 | } 1158 | ], 1159 | "error": null 1160 | } 1161 | ``` 1162 | 1163 | #### Media #### 1164 | 1165 | * **storeMediaFile** 1166 | 1167 | Stores a file with the specified base64-encoded contents inside the media folder. To prevent Anki from removing 1168 | files not used by any cards (e.g. for configuration files), prefix the filename with an underscore. These files are 1169 | still synchronized to AnkiWeb. 1170 | 1171 | *Sample request*: 1172 | ```json 1173 | { 1174 | "action": "storeMediaFile", 1175 | "version": 5, 1176 | "params": { 1177 | "filename": "_hello.txt", 1178 | "data": "SGVsbG8sIHdvcmxkIQ==" 1179 | } 1180 | } 1181 | ``` 1182 | 1183 | *Sample result*: 1184 | ```json 1185 | { 1186 | "result": null, 1187 | "error": null 1188 | } 1189 | ``` 1190 | 1191 | *Content of `_hello.txt`*: 1192 | ``` 1193 | Hello world! 1194 | ``` 1195 | 1196 | * **retrieveMediaFile** 1197 | 1198 | Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist. 1199 | 1200 | *Sample request*: 1201 | ```json 1202 | { 1203 | "action": "retrieveMediaFile", 1204 | "version": 5, 1205 | "params": { 1206 | "filename": "_hello.txt" 1207 | } 1208 | } 1209 | ``` 1210 | 1211 | *Sample result*: 1212 | ```json 1213 | { 1214 | "result": "SGVsbG8sIHdvcmxkIQ==", 1215 | "error": null 1216 | } 1217 | ``` 1218 | 1219 | * **deleteMediaFile** 1220 | 1221 | Deletes the specified file inside the media folder. 1222 | 1223 | *Sample request*: 1224 | ```json 1225 | { 1226 | "action": "deleteMediaFile", 1227 | "version": 5, 1228 | "params": { 1229 | "filename": "_hello.txt" 1230 | } 1231 | } 1232 | ``` 1233 | 1234 | *Sample result*: 1235 | ```json 1236 | { 1237 | "result": null, 1238 | "error": null 1239 | } 1240 | ``` 1241 | 1242 | #### Graphical #### 1243 | 1244 | * **guiBrowse** 1245 | 1246 | Invokes the *Card Browser* dialog and searches for a given query. Returns an array of identifiers of the cards that 1247 | were found. 1248 | 1249 | *Sample request*: 1250 | ```json 1251 | { 1252 | "action": "guiBrowse", 1253 | "version": 5, 1254 | "params": { 1255 | "query": "deck:current" 1256 | } 1257 | } 1258 | ``` 1259 | 1260 | *Sample result*: 1261 | ```json 1262 | { 1263 | "result": [1494723142483, 1494703460437, 1494703479525], 1264 | "error": null 1265 | } 1266 | ``` 1267 | 1268 | * **guiAddCards** 1269 | 1270 | Invokes the *Add Cards* dialog. 1271 | 1272 | *Sample request*: 1273 | ```json 1274 | { 1275 | "action": "guiAddCards", 1276 | "version": 5 1277 | } 1278 | ``` 1279 | 1280 | *Sample result*: 1281 | ```json 1282 | { 1283 | "result": null, 1284 | "error": null 1285 | } 1286 | ``` 1287 | 1288 | * **guiCurrentCard** 1289 | 1290 | Returns information about the current card or `null` if not in review mode. 1291 | 1292 | *Sample request*: 1293 | ```json 1294 | { 1295 | "action": "guiCurrentCard", 1296 | "version": 5 1297 | } 1298 | ``` 1299 | 1300 | *Sample result*: 1301 | ```json 1302 | { 1303 | "result": { 1304 | "answer": "back content", 1305 | "question": "front content", 1306 | "deckName": "Default", 1307 | "modelName": "Basic", 1308 | "fieldOrder": 0, 1309 | "fields": { 1310 | "Front": {"value": "front content", "order": 0}, 1311 | "Back": {"value": "back content", "order": 1} 1312 | }, 1313 | "cardId": 1498938915662, 1314 | "buttons": [1, 2, 3] 1315 | }, 1316 | "error": null 1317 | } 1318 | ``` 1319 | 1320 | * **guiStartCardTimer** 1321 | 1322 | Starts or resets the `timerStarted` value for the current card. This is useful for deferring the start time to when 1323 | it is displayed via the API, allowing the recorded time taken to answer the card to be more accurate when calling 1324 | `guiAnswerCard`. 1325 | 1326 | *Sample request*: 1327 | ```json 1328 | { 1329 | "action": "guiStartCardTimer", 1330 | "version": 5 1331 | } 1332 | ``` 1333 | 1334 | *Sample result*: 1335 | ```json 1336 | { 1337 | "result": true, 1338 | "error": null 1339 | } 1340 | ``` 1341 | 1342 | * **guiShowQuestion** 1343 | 1344 | Shows question text for the current card; returns `true` if in review mode or `false` otherwise. 1345 | 1346 | *Sample request*: 1347 | ```json 1348 | { 1349 | "action": "guiShowQuestion", 1350 | "version": 5 1351 | } 1352 | ``` 1353 | 1354 | *Sample result*: 1355 | ```json 1356 | { 1357 | "result": true, 1358 | "error": null 1359 | } 1360 | ``` 1361 | 1362 | * **guiShowAnswer** 1363 | 1364 | Shows answer text for the current card; returns `true` if in review mode or `false` otherwise. 1365 | 1366 | *Sample request*: 1367 | ```json 1368 | { 1369 | "action": "guiShowAnswer", 1370 | "version": 5 1371 | } 1372 | ``` 1373 | 1374 | *Sample result*: 1375 | ```json 1376 | { 1377 | "result": true, 1378 | "error": null 1379 | } 1380 | ``` 1381 | 1382 | * **guiAnswerCard** 1383 | 1384 | Answers the current card; returns `true` if succeeded or `false` otherwise. Note that the answer for the current 1385 | card must be displayed before before any answer can be accepted by Anki. 1386 | 1387 | *Sample request*: 1388 | ```json 1389 | { 1390 | "action": "guiAnswerCard", 1391 | "version": 5, 1392 | "params": { 1393 | "ease": 1 1394 | } 1395 | } 1396 | ``` 1397 | 1398 | *Sample result*: 1399 | ```json 1400 | { 1401 | "result": true, 1402 | "error": null 1403 | } 1404 | ``` 1405 | 1406 | * **guiDeckOverview** 1407 | 1408 | Opens the *Deck Overview* dialog for the deck with the given name; returns `true` if succeeded or `false` otherwise. 1409 | 1410 | *Sample request*: 1411 | ```json 1412 | { 1413 | "action": "guiDeckOverview", 1414 | "version": 5, 1415 | "params": { 1416 | "name": "Default" 1417 | } 1418 | } 1419 | ``` 1420 | 1421 | *Sample result*: 1422 | ```json 1423 | { 1424 | "result": true, 1425 | "error": null 1426 | } 1427 | ``` 1428 | 1429 | * **guiDeckBrowser** 1430 | 1431 | Opens the *Deck Browser* dialog. 1432 | 1433 | *Sample request*: 1434 | ```json 1435 | { 1436 | "action": "guiDeckBrowser", 1437 | "version": 5 1438 | } 1439 | ``` 1440 | 1441 | *Sample result*: 1442 | ```json 1443 | { 1444 | "result": null, 1445 | "error": null 1446 | } 1447 | ``` 1448 | 1449 | * **guiDeckReview** 1450 | 1451 | Starts review for the deck with the given name; returns `true` if succeeded or `false` otherwise. 1452 | 1453 | *Sample request*: 1454 | ```json 1455 | { 1456 | "action": "guiDeckReview", 1457 | "version": 5, 1458 | "params": { 1459 | "name": "Default" 1460 | } 1461 | } 1462 | ``` 1463 | 1464 | *Sample result*: 1465 | ```json 1466 | { 1467 | "result": true, 1468 | "error": null 1469 | } 1470 | ``` 1471 | 1472 | * **guiExitAnki** 1473 | 1474 | Schedules a request to gracefully close Anki. This operation is asynchronous, so it will return immediately and 1475 | won't wait until the Anki process actually terminates. 1476 | 1477 | *Sample request*: 1478 | ```json 1479 | { 1480 | "action": "guiExitAnki", 1481 | "version": 5 1482 | } 1483 | ``` 1484 | 1485 | *Sample result*: 1486 | ```json 1487 | { 1488 | "result": null, 1489 | "error": null 1490 | } 1491 | ``` 1492 | 1493 | ## License ## 1494 | 1495 | This program is free software: you can redistribute it and/or modify 1496 | it under the terms of the GNU General Public License as published by 1497 | the Free Software Foundation, either version 3 of the License, or 1498 | (at your option) any later version. 1499 | 1500 | This program is distributed in the hope that it will be useful, 1501 | but WITHOUT ANY WARRANTY; without even the implied warranty of 1502 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 1503 | GNU General Public License for more details. 1504 | 1505 | You should have received a copy of the GNU General Public License 1506 | along with this program. If not, see . 1507 | --------------------------------------------------------------------------------