Merge branch 'master' into travis-tests

This commit is contained in:
tomasgodoi 2017-08-29 00:13:57 -03:00
commit a36dfab38d
2 changed files with 1147 additions and 33 deletions

View File

@ -17,12 +17,18 @@
import anki import anki
import aqt import aqt
import base64
import hashlib import hashlib
import inspect import inspect
import json import json
import os
import os.path import os.path
import re
import select import select
import socket import socket
import sys
from time import time
from unicodedata import normalize
# #
@ -32,8 +38,8 @@ import socket
API_VERSION = 4 API_VERSION = 4
TICK_INTERVAL = 25 TICK_INTERVAL = 25
URL_TIMEOUT = 10 URL_TIMEOUT = 10
URL_UPGRADE = 'https://raw.githubusercontent.com/FooSoft/anki-connect/master/anki_connect.py' 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_BACKLOG = 5
NET_PORT = 8765 NET_PORT = 8765
@ -42,25 +48,21 @@ NET_PORT = 8765
# General helpers # General helpers
# #
try: if sys.version_info[0] < 3:
import urllib2 import urllib2
web = urllib2 web = urllib2
except ImportError:
from PyQt4.QtCore import QTimer
from PyQt4.QtGui import QMessageBox
else:
unicode = str
from urllib import request from urllib import request
web = request web = request
try:
from PyQt4.QtCore import QTimer
from PyQt4.QtGui import QMessageBox
except ImportError:
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
try:
unicode
except:
unicode = str
# #
# Helpers # Helpers
@ -179,10 +181,10 @@ class AjaxClient:
headers = {} headers = {}
for line in parts[0].split(makeBytes('\r\n')): for line in parts[0].split(makeBytes('\r\n')):
pair = line.split(makeBytes(': ')) 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 headerLength = len(parts[0]) + 4
bodyLength = int(headers.get(makeBytes('Content-Length'), 0)) bodyLength = int(headers.get(makeBytes('content-length'), 0))
totalLength = headerLength + bodyLength totalLength = headerLength + bodyLength
if totalLength > len(data): if totalLength > len(data):
@ -201,6 +203,26 @@ class AjaxServer:
self.handler = handler self.handler = handler
self.clients = [] self.clients = []
self.sock = None 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']
]
self.extraHeaders = {}
def getHeaders(self):
headers = self.headers[:]
for name in self.extraHeaders:
headers.append([name, self.extraHeaders[name]])
return headers
def advance(self): def advance(self):
@ -245,11 +267,9 @@ class AjaxServer:
body = json.dumps(None); body = json.dumps(None);
resp = bytes() resp = bytes()
headers = [
['HTTP/1.1 200 OK', None], self.setHeader('Content-Length', str(len(body)))
['Content-Type', 'text/json'], headers = self.getHeaders()
['Content-Length', str(len(body))]
]
for key, value in headers: for key, value in headers:
if value is None: if value is None:
@ -318,6 +338,29 @@ class AnkiNoteParams:
# #
class 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): def addNote(self, params):
collection = self.collection() collection = self.collection()
if collection is None: if collection is None:
@ -378,6 +421,79 @@ class AnkiBridge:
return note return note
def addTags(self, notes, tags, add=True):
self.startEditing()
self.collection().tags.bulkAdd(notes, tags, add)
self.stopEditing()
def suspend(self, cards, suspend=True):
for card in cards:
isSuspended = self.isSuspended(card)
if suspend and isSuspended:
cards.remove(card)
elif not suspend and not isSuspended:
cards.remove(card)
if cards:
self.startEditing()
if suspend:
self.collection().sched.suspendCards(cards)
else:
self.collection().sched.unsuspendCards(cards)
self.stopEditing()
return True
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)
return suspended
def areDue(self, cards):
due = []
for card in cards:
if self.findCards('cid:%s is:new' % card):
due.append(True)
continue
date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1]
if (ivl >= -1200):
if self.findCards('cid:%s is:due' % card):
due.append(True)
else:
due.append(False)
else:
if date - ivl <= time():
due.append(True)
else:
due.append(False)
return due
def getIntervals(self, cards, complete=False):
intervals = []
for card in cards:
if self.findCards('cid:%s is:new' % card):
intervals.append(0)
continue
interval = self.collection().db.list('select ivl from revlog where cid = ?', card)
if not complete:
interval = interval[-1]
intervals.append(interval)
return intervals
def startEditing(self): def startEditing(self):
self.window().requireReset() self.window().requireReset()
@ -403,6 +519,13 @@ class AnkiBridge:
return self.collection().sched return self.collection().sched
def multi(self, actions):
response = []
for item in actions:
response.append(AnkiConnect.handler(ac, item))
return response
def media(self): def media(self):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
@ -415,6 +538,18 @@ class AnkiBridge:
return collection.models.allNames() 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): def modelNameFromId(self, modelId):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
@ -431,12 +566,109 @@ class AnkiBridge:
return [field['name'] for field in model['flds']] return [field['name'] for field in model['flds']]
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): def deckNames(self):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
return collection.decks.allNames() 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): def deckNameFromId(self, deckId):
collection = self.collection() collection = self.collection()
if collection is not None: if collection is not None:
@ -445,6 +677,63 @@ class AnkiBridge:
return deck['name'] return deck['name']
def findNotes(self, query=None):
if query is not None:
return self.collection().findNotes(query)
else:
return []
def findCards(self, query=None):
if query is not None:
return self.collection().findCards(query)
else:
return []
def getDecks(self, cards):
decks = {}
for card in cards:
did = self.collection().db.scalar('select did from cards where id = ?', card)
deck = self.collection().decks.get(did)['name']
if deck in decks:
decks[deck].append(card)
else:
decks[deck] = [card]
return decks
def changeDeck(self, cards, deck):
self.startEditing()
did = self.collection().decks.id(deck)
mod = anki.utils.intTime()
usn = self.collection().usn()
# normal cards
scids = anki.utils.ids2str(cards)
# remove any cards from filtered deck first
self.collection().sched.remFromDyn(cards)
# then move into new deck
self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did)
self.stopEditing()
def deleteDecks(self, decks, cardsToo=False):
self.startEditing()
for deck in decks:
did = self.collection().decks.id(deck)
self.collection().decks.rem(did, cardsToo)
self.stopEditing()
def cardsToNotes(self, cards):
return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards))
def guiBrowse(self, query=None): def guiBrowse(self, query=None):
browser = aqt.dialogs.open('Browser', self.window()) browser = aqt.dialogs.open('Browser', self.window())
browser.activateWindow() browser.activateWindow()
@ -490,12 +779,24 @@ class AnkiBridge:
'fieldOrder': card.ord, 'fieldOrder': card.ord,
'question': card._getQA()['q'], 'question': card._getQA()['q'],
'answer': card._getQA()['a'], 'answer': card._getQA()['a'],
'buttons': map(lambda b: b[0], reviewer._answerButtonList()), 'buttons': [b[0] for b in reviewer._answerButtonList()],
'modelName': model['name'], 'modelName': model['name'],
'deckName': self.deckNameFromId(card.did) 'deckName': self.deckNameFromId(card.did)
} }
def guiStartCardTimer(self):
if not self.guiReviewActive():
return False
card = self.reviewer().card
if card is not None:
card.startTimer()
return True
else:
return False
def guiShowQuestion(self): def guiShowQuestion(self):
if self.guiReviewActive(): if self.guiReviewActive():
self.reviewer()._showQuestion() self.reviewer()._showQuestion()
@ -549,6 +850,13 @@ class AnkiBridge:
else: else:
return False 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.
# #
# AnkiConnect # AnkiConnect
@ -601,21 +909,81 @@ class AnkiConnect:
return handler(**params) return handler(**params)
@webApi
def multi(self, actions):
return self.anki.multi(actions)
@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 @webApi
def deckNames(self): def deckNames(self):
return self.anki.deckNames() return self.anki.deckNames()
@webApi
def deckNamesAndIds(self):
return self.anki.deckNamesAndIds()
@webApi @webApi
def modelNames(self): def modelNames(self):
return self.anki.modelNames() return self.anki.modelNames()
@webApi
def modelNamesAndIds(self):
return self.anki.modelNamesAndIds()
@webApi @webApi
def modelFieldNames(self, modelName): def modelFieldNames(self, modelName):
return self.anki.modelFieldNames(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 @webApi
def addNote(self, note): def addNote(self, note):
params = AnkiNoteParams(note) params = AnkiNoteParams(note)
@ -646,6 +1014,41 @@ class AnkiConnect:
return results return results
@webApi
def addTags(self, notes, tags, add=True):
return self.anki.addTags(notes, tags, add)
@webApi
def removeTags(self, notes, tags):
return self.anki.addTags(notes, tags, False)
@webApi
def suspend(self, cards, suspend=True):
return self.anki.suspend(cards, suspend)
@webApi
def unsuspend(self, cards):
return self.anki.suspend(cards, False)
@webApi
def areSuspended(self, cards):
return self.anki.areSuspended(cards)
@webApi
def areDue(self, cards):
return self.anki.areDue(cards)
@webApi
def getIntervals(self, cards, complete=False):
return self.anki.getIntervals(cards, complete)
@webApi @webApi
def upgrade(self): def upgrade(self):
response = QMessageBox.question( response = QMessageBox.question(
@ -658,7 +1061,7 @@ class AnkiConnect:
if response == QMessageBox.Yes: if response == QMessageBox.Yes:
data = download(URL_UPGRADE) data = download(URL_UPGRADE)
if data is None: if data is None:
QMessageBox.critical(self.anki.window, 'AnkiConnect', 'Failed to download latest version.') QMessageBox.critical(self.anki.window(), 'AnkiConnect', 'Failed to download latest version.')
else: else:
path = os.path.splitext(__file__)[0] + '.py' path = os.path.splitext(__file__)[0] + '.py'
with open(path, 'w') as fp: with open(path, 'w') as fp:
@ -674,6 +1077,36 @@ class AnkiConnect:
return API_VERSION return API_VERSION
@webApi
def findNotes(self, query=None):
return self.anki.findNotes(query)
@webApi
def findCards(self, query=None):
return self.anki.findCards(query)
@webApi
def getDecks(self, cards):
return self.anki.getDecks(cards)
@webApi
def changeDeck(self, cards, deck):
return self.anki.changeDeck(cards, deck)
@webApi
def deleteDecks(self, decks, cardsToo=False):
return self.anki.deleteDecks(decks, cardsToo)
@webApi
def cardsToNotes(self, cards):
return self.anki.cardsToNotes(cards)
@webApi @webApi
def guiBrowse(self, query=None): def guiBrowse(self, query=None):
return self.anki.guiBrowse(query) return self.anki.guiBrowse(query)
@ -689,6 +1122,11 @@ class AnkiConnect:
return self.anki.guiCurrentCard() return self.anki.guiCurrentCard()
@webApi
def guiStartCardTimer(self):
return self.anki.guiStartCardTimer()
@webApi @webApi
def guiAnswerCard(self, ease): def guiAnswerCard(self, ease):
return self.anki.guiAnswerCard(ease) return self.anki.guiAnswerCard(ease)
@ -716,7 +1154,11 @@ class AnkiConnect:
@webApi @webApi
def guiDeckReview(self, name): def guiDeckReview(self, name):
self.anki.guiDeckReview(name) return self.anki.guiDeckReview(name)
@webApi
def guiExitAnki(self):
return self.anki.guiExitAnki()
# #

692
README.md
View File

@ -45,6 +45,8 @@ 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 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. 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 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` to bind it to all network interfaces on your host.
### Sample Invocation ### ### Sample Invocation ###
Every request consists of a JSON-encoded object containing an *action*, and a set of contextual *parameters*. A simple Every request consists of a JSON-encoded object containing an *action*, and a set of contextual *parameters*. A simple
@ -84,12 +86,28 @@ curl localhost:8765 -X POST -d '{"action": "version"}'
Below is a list of currently supported actions. Requests with invalid actions or parameters will a return `null` result. Below is a list of currently supported actions. Requests with invalid actions or parameters will a return `null` result.
Categories:
* [Miscellaneous](#miscellaneous)
* [Decks](#decks)
* [Deck Configurations](#deck-configurations)
* [Models](#models)
* [Note Creation](#note-creation)
* [Note Tags](#note-tags)
* [Card Suspension](#card-suspension)
* [Card Intervals](#card-intervals)
* [Finding Notes and Cards](#finding-notes-and-cards)
* [Media File Storage](#media-file-storage)
* [Graphical](#graphical)
### Miscellaneous ###
* **version** * **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 `4` are defined.
This should be the first call you make to make sure that your application and AnkiConnect are able to communicate 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 will backwards compatible; as long as you are using actions 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. which are available in the reported AnkiConnect version or earlier, everything should work fine.
*Sample request*: *Sample request*:
@ -103,6 +121,55 @@ Below is a list of currently supported actions. Requests with invalid actions or
``` ```
4 4
``` ```
* **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*:
```
{
"action": "upgrade"
}
```
*Sample response*:
```
true
```
* **multi**
Performs multiple actions in one request, returning an array with the response of each action (in the given order).
*Sample request*:
```
{
"action": "multi",
"params": {
"actions": [
{"action": "deckNames"},
{
"action": "browse",
"params": {"query": "deck:current"}
}
]
}
}
```
*Sample response*:
```
[
["Default"],
[1494723142483, 1494703460437, 1494703479525]
]
```
### Decks ###
* **deckNames** * **deckNames**
Gets the complete list of deck names for the current user. Gets the complete list of deck names for the current user.
@ -121,6 +188,229 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
* **deckNamesAndIds**
Gets the complete list of deck names and their respective IDs for the current user.
*Sample request*:
```
{
"action": "deckNamesAndIds"
}
```
*Sample response*:
```
{
"Default": 1
}
```
* **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*:
```
{
"action": "getDecks",
"params": {
"cards": [1502298036657, 1502298033753, 1502032366472]
}
}
```
*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
```
### Deck Configurations ###
* **getDeckConfig**
Gets the config group object for the given deck.
*Sample request*:
```
{
"action": "getDeckConfig",
"params": {
"deck": "Default"
}
}
```
*Sample response*:
```
{
"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
}
```
* **saveDeckConfig**
Saves the given config group, returning `true` on success or `false` if the ID of the config group is invalid (i.e.
it does not exist).
*Sample request*:
```
{
"action": "saveDeckConfig",
"params": {
"config": (config group object)
}
}
```
*Sample response*:
```
true
```
* **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*:
```
{
"action": "setDeckConfigId",
"params": {
"decks": ["Default"],
"configId": 1
}
}
```
*Sample response*:
```
true
```
* **cloneDeckConfigId**
Creates a new config 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 config group, or `false` if the specified group to clone from does
not exist.
*Sample request*:
```
{
"action": "cloneDeckConfigId",
"params": {
"name": "Copy of Default",
"cloneFrom": 1
}
}
```
*Sample response*:
```
1502972374573
```
* **removeDeckConfigId**
Removes the config group with the given ID, returning `true` if successful, or `false` if attempting to remove
either the default config group (ID = 1) or a config group that does not exist.
*Sample request*:
```
{
"action": "removeDeckConfigId",
"params": {
"configId": 1502972374573
}
}
```
*Sample response*:
```
true
```
### Models ###
* **modelNames** * **modelNames**
Gets the complete list of model names for the current user. Gets the complete list of model names for the current user.
@ -140,6 +430,27 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
* **modelNamesAndIds**
Gets the complete list of model names and their corresponding IDs for the current user.
*Sample request*:
```
{
"action": "modelNamesAndIds"
}
```
*Sample response*:
```
{
"Basic": 1483883011648
"Basic (and reversed card)": 1483883011644
"Basic (optional reversed card)": 1483883011631
"Cloze": 1483883011630
}
```
* **modelFieldNames** * **modelFieldNames**
Gets the complete list of field names for the provided model name. Gets the complete list of field names for the provided model name.
@ -162,6 +473,37 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
* **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*:
```
{
"action": "modelFieldsOnTemplates",
"params": {
"modelName": "Basic (and reversed card)"
}
}
```
*Sample response*:
```
{
"Card 1": [
["Front"],
["Back"]
],
"Card 2": [
["Back"],
["Front"]
]
}
```
### Note Creation ###
* **addNote** * **addNote**
Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of
@ -277,6 +619,312 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
### Note Tags ###
* **addTags**
Adds tags to notes by note ID.
*Sample request*:
```
{
"action": "addTags",
"params": {
"notes": [1483959289817, 1483959291695],
"tags": "european-languages"
}
}
```
*Sample response*:
```
null
```
* **removeTags**
Remove tags from notes by note ID.
*Sample request*:
```
{
"action": "removeTags",
"params": {
"notes": [1483959289817, 1483959291695],
"tags": "european-languages"
}
}
```
*Sample response*:
```
null
```
### Card Suspension ###
* **suspend**
Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false`
otherwise.
*Sample request*:
```
{
"action": "suspend",
"params": {
"cards": [1483959291685, 1483959293217]
}
}
```
*Sample response*:
```
true
```
* **unsuspend**
Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false`
otherwise.
*Sample request*:
```
{
"action": "unsuspend",
"params": {
"cards": [1483959291685, 1483959293217]
}
}
```
*Sample response*:
```
true
```
* **areSuspended**
Returns an array indicating whether each of the given cards is suspended (in the same order).
*Sample request*:
```
{
"action": "areSuspended",
"params": {
"cards": [1483959291685, 1483959293217]
}
}
```
*Sample response*:
```
[false, true]
```
### Card Intervals ###
* **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.
*Sample request*:
```
{
"action": "areDue",
"params": {
"cards": [1483959291685, 1483959293217]
}
}
```
*Sample response*:
```
[false, true]
```
* **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.)
*Sample request 1*:
```
{
"action": "getIntervals",
"params": {
"cards": [1502298033753, 1502298036657]
}
}
```
*Sample response 1*:
```
[-14400, 3]
```
*Sample request 2*:
```
{
"action": "getIntervals",
"params": {
"cards": [1502298033753, 1502298036657],
"complete": true
}
}
```
*Sample response 2*:
```
[
[-120, -180, -240, -300, -360, -14400],
[-120, -180, -240, -300, -360, -14400, 1, 3]
]
```
### Finding Notes and Cards ###
* **findNotes**
Returns an array of note IDs for a given query (same query syntax as **guiBrowse**).
*Sample request*:
```
{
"action": "findNotes",
"params": {
"query": "deck:current"
}
}
```
*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).
*Sample request*:
```
{
"action": "findCards",
"params": {
"query": "deck:current"
}
}
```
*Sample response*:
```
[
1494723142483,
1494703460437,
1494703479525
]
```
* **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.
*Sample request*:
```
{
"action": "cardsToNotes",
"params": {
"cards": [1502098034045, 1502098034048, 1502298033753]
}
}
```
*Sample response*:
```
[
1502098029797,
1502298025183
]
```
### Media File Storage ###
* **storeMediaFile**
Stores a file with the specified base64-encoded contents inside the media folder.
Note: 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*:
```
{
"action": "storeMediaFile",
"params": {
"filename": "_hello.txt",
"data": "SGVsbG8sIHdvcmxkIQ=="
}
}
```
*Sample response*:
```
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*:
```
{
"action": "retrieveMediaFile",
"params": {
"filename": "_hello.txt"
}
}
```
*Sample response*:
```
"SGVsbG8sIHdvcmxkIQ=="
```
* **deleteMediaFile**
Deletes the specified file inside the media folder.
*Sample request*:
```
{
"action": "deleteMediaFile",
"params": {
"filename": "_hello.txt"
}
}
```
*Sample response*:
```
null
```
### Graphical ###
* **guiBrowse** * **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 and searches for a given query. Returns an array of identifiers of the cards that were found.
@ -350,6 +998,22 @@ Below is a list of currently supported actions. Requests with invalid actions or
} }
``` ```
* **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.
*Sample request*:
```
{
"action": "guiStartCardTimer"
}
```
*Sample response*:
```
true
```
* **guiShowQuestion** * **guiShowQuestion**
Shows question text for the current card; returns `true` if in review mode or `false` otherwise. Shows question text for the current card; returns `true` if in review mode or `false` otherwise.
@ -453,27 +1117,35 @@ Below is a list of currently supported actions. Requests with invalid actions or
*Sample response*: *Sample response*:
``` ```
null true
``` ```
* **guiExitAnki**
* **upgrade** Schedules a request to close Anki after 1s. This operation is asynchronous, so it will return immediately and won't wait until Anki actually exits.
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 request*:
``` ```
{ {
"action": "upgrade" "action": "guiExitAnki"
} }
``` ```
*Sample response*: *Sample response*:
``` ```
true null
``` ```
## License ## ## License ##
GPL This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.