This commit is contained in:
Alex Yatskov 2017-08-27 14:55:44 -07:00
commit 4c0f211dac
2 changed files with 487 additions and 39 deletions

View File

@ -17,14 +17,17 @@
import anki import anki
import aqt import aqt
import base64
import hashlib import hashlib
import inspect import inspect
import json import json
import os.path import os.path
import re
import select import select
import socket import socket
import sys import sys
from time import time from time import time
from unicodedata import normalize
# #
@ -334,6 +337,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:
@ -511,6 +537,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:
@ -527,6 +565,37 @@ 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): def getDeckConfig(self, deck):
if not deck in self.deckNames(): if not deck in self.deckNames():
return False return False
@ -837,6 +906,21 @@ class AnkiConnect:
return self.anki.multi(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()
@ -852,11 +936,21 @@ class AnkiConnect:
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 @webApi
def getDeckConfig(self, deck): def getDeckConfig(self, deck):
return self.anki.getDeckConfig(deck) return self.anki.getDeckConfig(deck)

432
README.md
View File

@ -84,12 +84,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*:
@ -104,6 +120,24 @@ 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** * **multi**
Performs multiple actions in one request, returning an array with the response of each action (in the given order). Performs multiple actions in one request, returning an array with the response of each action (in the given order).
@ -132,6 +166,8 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
### 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.
@ -150,47 +186,90 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
* **modelNames** * **deckNamesAndIds**
Gets the complete list of model names for the current user. Gets the complete list of deck names and their respective IDs for the current user.
*Sample request*: *Sample request*:
``` ```
{ {
"action": "modelNames" "action": "deckNamesAndIds"
} }
``` ```
*Sample response*: *Sample response*:
``` ```
[ {
"Basic", "Default": 1
"Basic (and reversed card)" }
]
``` ```
* **modelFieldNames** * **getDecks**
Gets the complete list of field names for the provided model name. Accepts an array of card IDs and returns an object with each deck name as a key, and its value an array of the given
cards which belong to it.
*Sample request*: *Sample request*:
``` ```
{ {
"action": "modelFieldNames", "action": "getDecks",
"params": { "params": {
"modelName": "Basic" "cards": [1502298036657, 1502298033753, 1502032366472]
} }
} }
``` ```
*Sample response*: *Sample response*:
``` ```
[ {
"Front", "Default": [1502032366472],
"Back" "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** * **getDeckConfig**
Gets the config group object for the given deck. Gets the config group object for the given deck.
@ -328,6 +407,101 @@ Below is a list of currently supported actions. Requests with invalid actions or
true true
``` ```
### Models ###
* **modelNames**
Gets the complete list of model names for the current user.
*Sample request*:
```
{
"action": "modelNames"
}
```
*Sample response*:
```
[
"Basic",
"Basic (and reversed card)"
]
```
* **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**
Gets the complete list of field names for the provided model name.
*Sample request*:
```
{
"action": "modelFieldNames",
"params": {
"modelName": "Basic"
}
}
```
*Sample response*:
```
[
"Front",
"Back"
]
```
* **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
@ -443,6 +617,8 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
### Note Tags ###
* **addTags** * **addTags**
Adds tags to notes by note ID. Adds tags to notes by note ID.
@ -483,9 +659,12 @@ Below is a list of currently supported actions. Requests with invalid actions or
null null
``` ```
### Card Suspension ###
* **suspend** * **suspend**
Suspend cards by card ID. Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false`
otherwise.
*Sample request*: *Sample request*:
``` ```
@ -499,12 +678,13 @@ Below is a list of currently supported actions. Requests with invalid actions or
*Sample response*: *Sample response*:
``` ```
null true
``` ```
* **unsuspend** * **unsuspend**
Unsuspend cards by card ID. Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false`
otherwise.
*Sample request*: *Sample request*:
``` ```
@ -518,9 +698,92 @@ Below is a list of currently supported actions. Requests with invalid actions or
*Sample response*: *Sample response*:
``` ```
null 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** * **findNotes**
Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). Returns an array of note IDs for a given query (same query syntax as **guiBrowse**).
@ -528,7 +791,7 @@ Below is a list of currently supported actions. Requests with invalid actions or
*Sample request*: *Sample request*:
``` ```
{ {
"action": "findCards", "action": "findNotes",
"params": { "params": {
"query": "deck:current" "query": "deck:current"
} }
@ -567,6 +830,99 @@ Below is a list of currently supported actions. Requests with invalid actions or
] ]
``` ```
* **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.
@ -640,6 +996,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.
@ -746,24 +1118,6 @@ Below is a list of currently supported actions. Requests with invalid actions or
true true
``` ```
* **upgrade**
Displays a confirmation dialog box in Anki asking the user if they wish to upgrade AnkiConnect to the latest version
from the project's [master branch](https://raw.githubusercontent.com/FooSoft/anki-connect/master/AnkiConnect.py) on
GitHub. Returns a boolean value indicating if the plugin was upgraded or not.
*Sample request*:
```
{
"action": "upgrade"
}
```
*Sample response*:
```
true
```
## License ## ## License ##
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify