diff --git a/AnkiConnect.py b/AnkiConnect.py index 8dececb..b5f2774 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -17,14 +17,17 @@ import anki import aqt +import base64 import hashlib import inspect import json import os.path +import re import select import socket import sys from time import time +from unicodedata import normalize # @@ -334,6 +337,29 @@ class AnkiNoteParams: # class AnkiBridge: + def storeMediaFile(self, filename, data): + self.deleteMediaFile(filename) + self.media().writeData(filename, base64.b64decode(data)) + + + def retrieveMediaFile(self, filename): + # based on writeData from anki/media.py + filename = os.path.basename(filename) + filename = normalize("NFC", filename) + filename = self.media().stripIllegal(filename) + + path = os.path.join(self.media().dir(), filename) + if os.path.exists(path): + with open(path, 'rb') as file: + return base64.b64encode(file.read()).decode('ascii') + + return False + + + def deleteMediaFile(self, filename): + self.media().syncDelete(filename) + + def addNote(self, params): collection = self.collection() if collection is None: @@ -511,6 +537,18 @@ class AnkiBridge: return collection.models.allNames() + def modelNamesAndIds(self): + models = {} + + modelNames = self.modelNames() + for model in modelNames: + mid = self.collection().models.byName(model)['id'] + mid = int(mid) # sometimes Anki stores the ID as a string + models[model] = mid + + return models + + def modelNameFromId(self, modelId): collection = self.collection() if collection is not None: @@ -527,6 +565,37 @@ class AnkiBridge: 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 @@ -837,6 +906,21 @@ class AnkiConnect: 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 def deckNames(self): return self.anki.deckNames() @@ -852,11 +936,21 @@ class AnkiConnect: return self.anki.modelNames() + @webApi + def modelNamesAndIds(self): + return self.anki.modelNamesAndIds() + + @webApi def modelFieldNames(self, modelName): return self.anki.modelFieldNames(modelName) + @webApi + def modelFieldsOnTemplates(self, modelName): + return self.anki.modelFieldsOnTemplates(modelName) + + @webApi def getDeckConfig(self, deck): return self.anki.getDeckConfig(deck) diff --git a/README.md b/README.md index 4c388d4..219a110 100644 --- a/README.md +++ b/README.md @@ -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. +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** 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 - 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. *Sample request*: @@ -104,6 +120,24 @@ Below is a list of currently supported actions. Requests with invalid actions or 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). @@ -132,6 +166,8 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +### Decks ### + * **deckNames** 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*: ``` { - "action": "modelNames" + "action": "deckNamesAndIds" } ``` *Sample response*: ``` - [ - "Basic", - "Basic (and reversed card)" - ] + { + "Default": 1 + } ``` -* **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*: ``` { - "action": "modelFieldNames", + "action": "getDecks", "params": { - "modelName": "Basic" + "cards": [1502298036657, 1502298033753, 1502032366472] } } ``` *Sample response*: ``` - [ - "Front", - "Back" - ] + { + "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. @@ -328,6 +407,101 @@ Below is a list of currently supported actions. Requests with invalid actions or 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** 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** 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 ``` +### Card Suspension ### + * **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*: ``` @@ -499,12 +678,13 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - null + true ``` * **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*: ``` @@ -518,9 +698,92 @@ Below is a list of currently supported actions. Requests with invalid actions or *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** 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*: ``` { - "action": "findCards", + "action": "findNotes", "params": { "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** 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** 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 ``` -* **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 ## This program is free software: you can redistribute it and/or modify