From d64643baa6b00783275765a0789b8e60480090af Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 08:53:35 +0100 Subject: [PATCH 1/5] Add media file actions --- AnkiConnect.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 8dececb..13c6a97 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -17,6 +17,7 @@ import anki import aqt +import base64 import hashlib import inspect import json @@ -334,6 +335,45 @@ class AnkiNoteParams: # class AnkiBridge: + def getFilePath(self, filename): + mediaFolder = self.collection().media.dir() + filePath = os.path.normpath(os.path.join(mediaFolder, filename)) + # catch attempts to write outside the media folder + if os.path.commonprefix([mediaFolder, filePath]) != mediaFolder: + return False + + return filePath + + + def storeFile(self, filename, data): + filePath = self.getFilePath(filename) + if filePath: + with open(filePath, 'wb') as file: + file.write(base64.b64decode(data)) + return True + + return False + + + def retrieveFile(self, filename): + filePath = self.getFilePath(filename) + if filePath and os.path.isfile(filePath): + with open(filePath, 'rb') as file: + data = base64.b64encode(file.read()) + return data.decode('ascii') + + return False + + + def deleteFile(self, filename): + filePath = self.getFilePath(filename) + if filePath and os.path.isfile(filePath): + os.remove(filePath) + return True + + return False + + def addNote(self, params): collection = self.collection() if collection is None: @@ -837,6 +877,21 @@ class AnkiConnect: return self.anki.multi(actions) + @webApi + def storeFile(self, filename, data): + return self.anki.storeFile(filename, data) + + + @webApi + def retrieveFile(self, filename): + return self.anki.retrieveFile(filename) + + + @webApi + def deleteFile(self, filename): + return self.anki.deleteFile(filename) + + @webApi def deckNames(self): return self.anki.deckNames() From c689e8276bca3de1dbacfc9fce880cc391152c2e Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 09:12:49 +0100 Subject: [PATCH 2/5] Document media actions --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/README.md b/README.md index 1f35bcc..8c3d94a 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,75 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +* **storeFile** + + Stores a file with the specified base64-encoded contents inside the media folder. Returns `true` upon success or + `false` if attempting to write a file outside 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": "storeFile", + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *Sample response*: + ``` + true + ``` + + *Content of `_hello.txt`*: + ``` + Hello world! + ``` + +* **retrieveFile** + + Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist or if + attempting to read a file outside the media folder. + + *Sample request*: + ``` + { + "action": "retrieveFile", + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample response*: + ``` + "SGVsbG8sIHdvcmxkIQ==" + ``` + +* **deleteFile** + + Deletes the specified file inside the media folder, returning `true` if successful, or `false` if the file does not + exist or if attempting to delete a file outside the media folder. + + *Sample request*: + ``` + { + "action": "deleteFile", + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample response*: + ``` + true + ``` + * **deckNames** Gets the complete list of deck names for the current user. From a95c56a0615298e02da5d1c55350fb8eba67c7fc Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 09:43:37 +0100 Subject: [PATCH 3/5] Organize README into categories --- README.md | 390 +++++++++++++++++++++++++++++------------------------- 1 file changed, 212 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 8c3d94a..0836655 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,22 @@ 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) +* [File Storage](#file-storage) +* [Graphical](#graphical) + +### Miscellaneous ### + * **version** Gets the version of the API exposed by this plugin. Currently versions `1` through `4` are defined. @@ -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,74 +166,7 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` -* **storeFile** - - Stores a file with the specified base64-encoded contents inside the media folder. Returns `true` upon success or - `false` if attempting to write a file outside 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": "storeFile", - "params": { - "filename": "_hello.txt", - "data": "SGVsbG8sIHdvcmxkIQ==" - } - } - ``` - - *Sample response*: - ``` - true - ``` - - *Content of `_hello.txt`*: - ``` - Hello world! - ``` - -* **retrieveFile** - - Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist or if - attempting to read a file outside the media folder. - - *Sample request*: - ``` - { - "action": "retrieveFile", - "params": { - "filename": "_hello.txt" - } - } - ``` - - *Sample response*: - ``` - "SGVsbG8sIHdvcmxkIQ==" - ``` - -* **deleteFile** - - Deletes the specified file inside the media folder, returning `true` if successful, or `false` if the file does not - exist or if attempting to delete a file outside the media folder. - - *Sample request*: - ``` - { - "action": "deleteFile", - "params": { - "filename": "_hello.txt" - } - } - ``` - - *Sample response*: - ``` - true - ``` +### Decks ### * **deckNames** @@ -237,47 +204,72 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` -* **modelNames** +* **getDecks** - Gets the complete list of model names for the current user. + 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": "modelNames" - } - ``` - - *Sample response*: - ``` - [ - "Basic", - "Basic (and reversed card)" - ] - ``` - -* **modelFieldNames** - - Gets the complete list of field names for the provided model name. - - *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. @@ -415,6 +407,51 @@ 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)" + ] + ``` + +* **modelFieldNames** + + Gets the complete list of field names for the provided model name. + + *Sample request*: + ``` + { + "action": "modelFieldNames", + "params": { + "modelName": "Basic" + } + } + ``` + + *Sample response*: + ``` + [ + "Front", + "Back" + ] + ``` + +### Note Creation ### + * **addNote** Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of @@ -530,6 +567,8 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +### Note Tags ### + * **addTags** Adds tags to notes by note ID. @@ -570,6 +609,8 @@ Below is a list of currently supported actions. Requests with invalid actions or null ``` +### Card Suspension ### + * **suspend** Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` @@ -629,6 +670,8 @@ Below is a list of currently supported actions. Requests with invalid actions or [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 @@ -689,6 +732,7 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +### Finding Notes and Cards ### * **findNotes** @@ -736,71 +780,6 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` -* **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 - ``` - * **cardsToNotes** Returns an (unordered) array of note IDs for the given card IDs. For cards with the same note, the ID is only @@ -824,6 +803,79 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +### File Storage ### + +* **storeFile** + + Stores a file with the specified base64-encoded contents inside the media folder. Returns `true` upon success or + `false` if attempting to write a file outside 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": "storeFile", + "params": { + "filename": "_hello.txt", + "data": "SGVsbG8sIHdvcmxkIQ==" + } + } + ``` + + *Sample response*: + ``` + true + ``` + + *Content of `_hello.txt`*: + ``` + Hello world! + ``` + +* **retrieveFile** + + Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist or if + attempting to read a file outside the media folder. + + *Sample request*: + ``` + { + "action": "retrieveFile", + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample response*: + ``` + "SGVsbG8sIHdvcmxkIQ==" + ``` + +* **deleteFile** + + Deletes the specified file inside the media folder, returning `true` if successful, or `false` if the file does not + exist or if attempting to delete a file outside the media folder. + + *Sample request*: + ``` + { + "action": "deleteFile", + "params": { + "filename": "_hello.txt" + } + } + ``` + + *Sample response*: + ``` + true + ``` + +### Graphical ### + * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. @@ -1019,24 +1071,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 From 56e8023d5f443063c469b54142ba5dfebe663963 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 09:51:01 +0100 Subject: [PATCH 4/5] Update with model actions --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 0836655..754c32a 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,27 @@ Categories: ] ``` +* **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. @@ -450,6 +471,35 @@ Categories: ] ``` +* **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** From 04cf33a1d0a76891c9c7691251360b5c7cd0dfeb Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 20:05:12 +0100 Subject: [PATCH 5/5] Update with requested changes --- AnkiConnect.py | 59 +++++++++++++++++++------------------------------- README.md | 29 +++++++++++-------------- 2 files changed, 35 insertions(+), 53 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 40f29b3..b5f2774 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -27,6 +27,7 @@ import select import socket import sys from time import time +from unicodedata import normalize # @@ -336,43 +337,27 @@ class AnkiNoteParams: # class AnkiBridge: - def getFilePath(self, filename): - mediaFolder = self.collection().media.dir() - filePath = os.path.normpath(os.path.join(mediaFolder, filename)) - # catch attempts to write outside the media folder - if os.path.commonprefix([mediaFolder, filePath]) != mediaFolder: - return False - - return filePath + def storeMediaFile(self, filename, data): + self.deleteMediaFile(filename) + self.media().writeData(filename, base64.b64decode(data)) - def storeFile(self, filename, data): - filePath = self.getFilePath(filename) - if filePath: - with open(filePath, 'wb') as file: - file.write(base64.b64decode(data)) - return True + 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 retrieveFile(self, filename): - filePath = self.getFilePath(filename) - if filePath and os.path.isfile(filePath): - with open(filePath, 'rb') as file: - data = base64.b64encode(file.read()) - return data.decode('ascii') - - return False - - - def deleteFile(self, filename): - filePath = self.getFilePath(filename) - if filePath and os.path.isfile(filePath): - os.remove(filePath) - return True - - return False + def deleteMediaFile(self, filename): + self.media().syncDelete(filename) def addNote(self, params): @@ -922,18 +907,18 @@ class AnkiConnect: @webApi - def storeFile(self, filename, data): - return self.anki.storeFile(filename, data) + def storeMediaFile(self, filename, data): + return self.anki.storeMediaFile(filename, data) @webApi - def retrieveFile(self, filename): - return self.anki.retrieveFile(filename) + def retrieveMediaFile(self, filename): + return self.anki.retrieveMediaFile(filename) @webApi - def deleteFile(self, filename): - return self.anki.deleteFile(filename) + def deleteMediaFile(self, filename): + return self.anki.deleteMediaFile(filename) @webApi diff --git a/README.md b/README.md index 754c32a..219a110 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Categories: * [Card Suspension](#card-suspension) * [Card Intervals](#card-intervals) * [Finding Notes and Cards](#finding-notes-and-cards) -* [File Storage](#file-storage) +* [Media File Storage](#media-file-storage) * [Graphical](#graphical) ### Miscellaneous ### @@ -853,12 +853,11 @@ Categories: ] ``` -### File Storage ### +### Media File Storage ### -* **storeFile** +* **storeMediaFile** - Stores a file with the specified base64-encoded contents inside the media folder. Returns `true` upon success or - `false` if attempting to write a file outside the media folder. + 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. @@ -866,7 +865,7 @@ Categories: *Sample request*: ``` { - "action": "storeFile", + "action": "storeMediaFile", "params": { "filename": "_hello.txt", "data": "SGVsbG8sIHdvcmxkIQ==" @@ -876,7 +875,7 @@ Categories: *Sample response*: ``` - true + null ``` *Content of `_hello.txt`*: @@ -884,15 +883,14 @@ Categories: Hello world! ``` -* **retrieveFile** +* **retrieveMediaFile** - Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist or if - attempting to read a file outside the media folder. + Retrieves the base64-encoded contents of the specified file, returning `false` if the file does not exist. *Sample request*: ``` { - "action": "retrieveFile", + "action": "retrieveMediaFile", "params": { "filename": "_hello.txt" } @@ -904,15 +902,14 @@ Categories: "SGVsbG8sIHdvcmxkIQ==" ``` -* **deleteFile** +* **deleteMediaFile** - Deletes the specified file inside the media folder, returning `true` if successful, or `false` if the file does not - exist or if attempting to delete a file outside the media folder. + Deletes the specified file inside the media folder. *Sample request*: ``` { - "action": "deleteFile", + "action": "deleteMediaFile", "params": { "filename": "_hello.txt" } @@ -921,7 +918,7 @@ Categories: *Sample response*: ``` - true + null ``` ### Graphical ###