From 55174730727506a0c6fbe9c5ffe3d67583f29ef5 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Wed, 5 Jul 2017 21:29:46 -0700 Subject: [PATCH 01/52] more reliable testing for anki alpha --- AnkiConnect.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 5290fbe..1afd0b9 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -23,6 +23,7 @@ import json import os.path import select import socket +import sys # @@ -42,25 +43,21 @@ NET_PORT = 8765 # General helpers # -try: +if sys.version_info[0] < 3: import urllib2 web = urllib2 -except ImportError: + + from PyQt4.QtCore import QTimer + from PyQt4.QtGui import QMessageBox +else: + unicode = str + from urllib import request web = request -try: - from PyQt4.QtCore import QTimer - from PyQt4.QtGui import QMessageBox -except ImportError: from PyQt5.QtCore import QTimer from PyQt5.QtWidgets import QMessageBox -try: - unicode -except: - unicode = str - # # Helpers From d9f94652b2987919a0a0bbc8e04b3d89cdda4c8c Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 21 Jul 2017 01:50:12 +0100 Subject: [PATCH 02/52] Add fast non-GUI browse function --- AnkiConnect.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 1afd0b9..35ec7cf 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -456,6 +456,13 @@ class AnkiBridge: return browser.model.cards + def browse(self, query=None): + if query is not None: + return aqt.mw.col.findCards(query) + else: + return [] + + def guiAddCards(self): addCards = aqt.dialogs.open('AddCards', self.window()) addCards.activateWindow() @@ -676,6 +683,11 @@ class AnkiConnect: return self.anki.guiBrowse(query) + @webApi + def browse(self, query=None): + return self.anki.browse(query) + + @webApi def guiAddCards(self): return self.anki.guiAddCards() From 81c069342cb47079a43f780e6a4b2a6bf57492c9 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 21 Jul 2017 12:45:31 +0100 Subject: [PATCH 03/52] Update README with non-gui browse documentation --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 76b13f0..0c7c643 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,29 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +* **browse** + + Functionally identical to **guiBrowse**, but accesses the database without using the GUI for increased performance. + + *Sample request*: + ``` + { + "action": "browse", + "params": { + "query": "deck:current" + } + } + ``` + + *Sample response*: + ``` + [ + 1494723142483, + 1494703460437, + 1494703479525 + ] + ``` + * **guiAddCards** Invokes the AddCards dialog. From b8e63c383da11f1268bc94bbf84e9556c3ee181f Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 27 Jul 2017 18:32:34 +0100 Subject: [PATCH 04/52] Add multi function --- AnkiConnect.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 35ec7cf..130ae2a 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -428,6 +428,13 @@ class AnkiBridge: return [field['name'] for field in model['flds']] + def multi(self, actions): + response = [] + for item in actions: + response.append(AnkiConnect.handler(ac, item)) + return response + + def deckNames(self): collection = self.collection() if collection is not None: @@ -620,6 +627,11 @@ class AnkiConnect: return self.anki.modelFieldNames(modelName) + @webApi + def multi(self, actions): + return self.anki.multi(actions) + + @webApi def addNote(self, note): params = AnkiNoteParams(note) From a129f8365b83381776e71f8bfb3a939476fe57cf Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 27 Jul 2017 18:54:02 +0100 Subject: [PATCH 05/52] Update README for multi action --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 0c7c643..deca307 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,34 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +* **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] + ] + ``` + * **addNote** Creates a note using the given deck and model, with the provided field values and tags. Returns the identifier of From 50300b55b5adefd48b5e95caab2c9829b9bf945a Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 30 Jul 2017 11:48:57 +0100 Subject: [PATCH 06/52] Fix bug where guiDeckReview always returns null --- AnkiConnect.py | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 130ae2a..f323d50 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -737,7 +737,7 @@ class AnkiConnect: @webApi def guiDeckReview(self, name): - self.anki.guiDeckReview(name) + return self.anki.guiDeckReview(name) # diff --git a/README.md b/README.md index deca307..defffd8 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - null + true ``` * **upgrade** From 5dfffadda79de1921c1ab989addb4b8e2bc77afa Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 30 Jul 2017 17:32:28 -0700 Subject: [PATCH 07/52] Updating README.md --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index defffd8..a824339 100644 --- a/README.md +++ b/README.md @@ -527,4 +527,15 @@ Below is a list of currently supported actions. Requests with invalid actions or ## 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 . From e8088146f37d3d2f4290ca7013b56ac6ab0fbf2c Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 3 Aug 2017 21:07:22 +0100 Subject: [PATCH 08/52] Add tagging and suspend functions --- AnkiConnect.py | 34 ++++++++++++++++++++++ README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index f323d50..82798a8 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -375,6 +375,20 @@ class AnkiBridge: return note + def addTags(self, query, tags, add=True): + notes = aqt.mw.col.findNotes(query) + aqt.mw.col.tags.bulkAdd(notes, tags, add) + + + def suspend(self, query, suspend=True): + cards = aqt.mw.col.findCards(query) + if suspend: + suspendFunction = aqt.mw.col.sched.suspendCards + else: + suspendFunction = aqt.mw.col.sched.unsuspendCards + suspendFunction(cards) + + def startEditing(self): self.window().requireReset() @@ -662,6 +676,26 @@ class AnkiConnect: return results + @webApi + def addTags(self, query, tags, add=True): + return self.anki.addTags(query, tags, add) + + + @webApi + def removeTags(self, query, tags): + return self.anki.addTags(query, tags, False) + + + @webApi + def suspend(self, query, suspend=True): + return self.anki.suspend(query, suspend) + + + @webApi + def unsuspend(self, query): + return self.anki.suspend(query, False) + + @webApi def upgrade(self): response = QMessageBox.question( diff --git a/README.md b/README.md index a824339..b0ba8f5 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,84 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +* **addTags** + + Adds tags to notes matching a query (same syntax as **browse**/**guiBrowse**). + + *Sample request*: + ``` + { + "action": "addTags", + "params": { + "query": "deck:French or deck:Spanish", + "tags": "european-languages" + } + } + ``` + + *Sample response*: + ``` + null + ``` + +* **removeTags** + + Remove tags from notes matching a query (same syntax as **browse**/**guiBrowse**). + + *Sample request*: + ``` + { + "action": "removeTags", + "params": { + "query": "deck:Japanese or deck:Chinese", + "tags": "european-languages" + } + } + ``` + + *Sample response*: + ``` + null + ``` + +* **suspend** + + Suspend cards matching a query (same syntax as **browse**/**guiBrowse**). + + *Sample request*: + ``` + { + "action": "suspend", + "params": { + "query": "tag:difficult" + } + } + ``` + + *Sample response*: + ``` + null + ``` + +* **unsuspend** + + Unsuspend cards matching a query (same syntax as **browse**/**guiBrowse**). + + *Sample request*: + ``` + { + "action": "unsuspend", + "params": { + "query": "tag:easy" + } + } + ``` + + *Sample response*: + ``` + null + ``` + * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. From a78d40457cb9aa06a1ef44ba1841675dee5fbf0e Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 3 Aug 2017 22:21:59 +0100 Subject: [PATCH 09/52] Change behaviour of tag/suspend functions --- AnkiConnect.py | 27 ++++++++++++--------------- README.md | 16 ++++++++-------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 82798a8..a09006e 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -375,18 +375,15 @@ class AnkiBridge: return note - def addTags(self, query, tags, add=True): - notes = aqt.mw.col.findNotes(query) + def addTags(self, notes, tags, add=True): aqt.mw.col.tags.bulkAdd(notes, tags, add) - def suspend(self, query, suspend=True): - cards = aqt.mw.col.findCards(query) + def suspend(self, cards, suspend=True): if suspend: - suspendFunction = aqt.mw.col.sched.suspendCards + aqt.mw.col.sched.suspendCards(cards) else: - suspendFunction = aqt.mw.col.sched.unsuspendCards - suspendFunction(cards) + aqt.mw.col.sched.unsuspendCards(cards) def startEditing(self): @@ -677,23 +674,23 @@ class AnkiConnect: @webApi - def addTags(self, query, tags, add=True): - return self.anki.addTags(query, tags, add) + def addTags(self, notes, tags, add=True): + return self.anki.addTags(notes, tags, add) @webApi - def removeTags(self, query, tags): - return self.anki.addTags(query, tags, False) + def removeTags(self, notes, tags): + return self.anki.addTags(notes, tags, False) @webApi - def suspend(self, query, suspend=True): - return self.anki.suspend(query, suspend) + def suspend(self, cards, suspend=True): + return self.anki.suspend(cards, suspend) @webApi - def unsuspend(self, query): - return self.anki.suspend(query, False) + def unsuspend(self, cards): + return self.anki.suspend(cards, False) @webApi diff --git a/README.md b/README.md index b0ba8f5..cc003ba 100644 --- a/README.md +++ b/README.md @@ -307,14 +307,14 @@ Below is a list of currently supported actions. Requests with invalid actions or * **addTags** - Adds tags to notes matching a query (same syntax as **browse**/**guiBrowse**). + Adds tags to notes by note ID. *Sample request*: ``` { "action": "addTags", "params": { - "query": "deck:French or deck:Spanish", + "notes": [1483959289817, 1483959291695], "tags": "european-languages" } } @@ -327,14 +327,14 @@ Below is a list of currently supported actions. Requests with invalid actions or * **removeTags** - Remove tags from notes matching a query (same syntax as **browse**/**guiBrowse**). + Remove tags from notes by note ID. *Sample request*: ``` { "action": "removeTags", "params": { - "query": "deck:Japanese or deck:Chinese", + "notes": [1483959289817, 1483959291695], "tags": "european-languages" } } @@ -347,14 +347,14 @@ Below is a list of currently supported actions. Requests with invalid actions or * **suspend** - Suspend cards matching a query (same syntax as **browse**/**guiBrowse**). + Suspend cards by card ID. *Sample request*: ``` { "action": "suspend", "params": { - "query": "tag:difficult" + "cards": [1483959291685, 1483959293217] } } ``` @@ -366,14 +366,14 @@ Below is a list of currently supported actions. Requests with invalid actions or * **unsuspend** - Unsuspend cards matching a query (same syntax as **browse**/**guiBrowse**). + Unsuspend cards by card ID. *Sample request*: ``` { "action": "unsuspend", "params": { - "query": "tag:easy" + "cards": [1483959291685, 1483959293217] } } ``` From 30f3d5618a6347948b21b629e06537b43aa121d2 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 3 Aug 2017 22:31:47 +0100 Subject: [PATCH 10/52] Rename browse to findCards and add new findNotes function --- AnkiConnect.py | 34 +++++++++++++++++++++++----------- README.md | 35 +++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index a09006e..f1e0aec 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -460,6 +460,20 @@ class AnkiBridge: return deck['name'] + def findNotes(self, query=None): + if query is not None: + return aqt.mw.col.findNotes(query) + else: + return [] + + + def findCards(self, query=None): + if query is not None: + return aqt.mw.col.findCards(query) + else: + return [] + + def guiBrowse(self, query=None): browser = aqt.dialogs.open('Browser', self.window()) browser.activateWindow() @@ -474,13 +488,6 @@ class AnkiBridge: return browser.model.cards - def browse(self, query=None): - if query is not None: - return aqt.mw.col.findCards(query) - else: - return [] - - def guiAddCards(self): addCards = aqt.dialogs.open('AddCards', self.window()) addCards.activateWindow() @@ -722,13 +729,18 @@ class AnkiConnect: @webApi - def guiBrowse(self, query=None): - return self.anki.guiBrowse(query) + def findNotes(self, query=None): + return self.anki.findNotes(query) @webApi - def browse(self, query=None): - return self.anki.browse(query) + def findCards(self, query=None): + return self.anki.findCards(query) + + + @webApi + def guiBrowse(self, query=None): + return self.anki.guiBrowse(query) @webApi diff --git a/README.md b/README.md index cc003ba..980f4fe 100644 --- a/README.md +++ b/README.md @@ -383,14 +383,37 @@ Below is a list of currently supported actions. Requests with invalid actions or null ``` -* **guiBrowse** +* **findNotes** - Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. + Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). *Sample request*: ``` { - "action": "guiBrowse", + "action": "findCards", + "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" } @@ -406,14 +429,14 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` -* **browse** +* **guiBrowse** - Functionally identical to **guiBrowse**, but accesses the database without using the GUI for increased performance. + Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. *Sample request*: ``` { - "action": "browse", + "action": "guiBrowse", "params": { "query": "deck:current" } From 3f755cbd08ad9e1eaa3d8018255bf827949ba6f2 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sat, 5 Aug 2017 09:24:03 +0100 Subject: [PATCH 11/52] Call startEditing before / stopEditing after tagging --- AnkiConnect.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index f1e0aec..7d32328 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -376,7 +376,9 @@ class AnkiBridge: def addTags(self, notes, tags, add=True): + self.startEditing() aqt.mw.col.tags.bulkAdd(notes, tags, add) + self.stopEditing() def suspend(self, cards, suspend=True): From dc01014daf1a9e7aeebdc0b15d39872cb3d7a150 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 6 Aug 2017 02:29:59 +0100 Subject: [PATCH 12/52] Fix issue #20; add isSuspended function; update code style --- AnkiConnect.py | 38 ++++++++++++++++++++++++++++++++------ README.md | 29 +++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 7d32328..36d2f7c 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -377,15 +377,36 @@ class AnkiBridge: def addTags(self, notes, tags, add=True): self.startEditing() - aqt.mw.col.tags.bulkAdd(notes, tags, add) + self.collection().tags.bulkAdd(notes, tags, add) self.stopEditing() def suspend(self, cards, suspend=True): - if suspend: - aqt.mw.col.sched.suspendCards(cards) + 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 isSuspended(self, card): + card = self.collection().getCard(card) + if card.queue == -1: + return True else: - aqt.mw.col.sched.unsuspendCards(cards) + return False def startEditing(self): @@ -464,14 +485,14 @@ class AnkiBridge: def findNotes(self, query=None): if query is not None: - return aqt.mw.col.findNotes(query) + return self.collection().findNotes(query) else: return [] def findCards(self, query=None): if query is not None: - return aqt.mw.col.findCards(query) + return self.collection().findCards(query) else: return [] @@ -702,6 +723,11 @@ class AnkiConnect: return self.anki.suspend(cards, False) + @webApi + def isSuspended(self, card): + return self.anki.isSuspended(card) + + @webApi def upgrade(self): response = QMessageBox.question( diff --git a/README.md b/README.md index 980f4fe..b3ec799 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,8 @@ Below is a list of currently supported actions. Requests with invalid actions or * **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*: ``` @@ -361,12 +362,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*: ``` @@ -380,7 +382,26 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - null + true + ``` + +* **isSuspended** + + Returns `true` if the given card is suspended or `false` otherwise. + + *Sample request*: + ``` + { + "action": "isSuspended", + "params": { + "card": 1483959291685 + } + } + ``` + + *Sample response*: + ``` + false ``` * **findNotes** From 1a2c559ca20a7c844b340bf6b53a43c55db70e83 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Wed, 9 Aug 2017 18:40:09 +0100 Subject: [PATCH 13/52] Add getIntervals function --- AnkiConnect.py | 15 +++++++++++++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 36d2f7c..42e9367 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -409,6 +409,16 @@ class AnkiBridge: return False + def getIntervals(self, cards, complete=False): + intervals = [] + for card in cards: + interval = self.window().col.db.list('select ivl from revlog where cid = ?', card) + if not complete: + interval = interval[-1] + intervals.append(interval) + return intervals + + def startEditing(self): self.window().requireReset() @@ -728,6 +738,11 @@ class AnkiConnect: return self.anki.isSuspended(card) + @webApi + def getIntervals(self, cards, complete=False): + return self.anki.getIntervals(cards, complete) + + @webApi def upgrade(self): response = QMessageBox.question( diff --git a/README.md b/README.md index b3ec799..04a35fb 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,46 @@ Below is a list of currently supported actions. Requests with invalid actions or false ``` +* **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] + ] + ``` + + * **findNotes** Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). From 6a6f9d7d2a10bc5df2420e4f80621d7ae8054797 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Wed, 9 Aug 2017 18:55:31 +0100 Subject: [PATCH 14/52] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 04a35fb..bd9858c 100644 --- a/README.md +++ b/README.md @@ -451,7 +451,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "findCards", + "action": "findNotes", "params": { "query": "deck:current" } From e02f8b2d65898ef56be8d28ee5f40ceea7fa4090 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Wed, 9 Aug 2017 19:05:00 +0100 Subject: [PATCH 15/52] Add cardsToNotes function --- AnkiConnect.py | 9 +++++++++ README.md | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 42e9367..c6f4897 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -507,6 +507,10 @@ class AnkiBridge: return [] + def cardsToNotes(self, cards): + return self.window().col.db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) + + def guiBrowse(self, query=None): browser = aqt.dialogs.open('Browser', self.window()) browser.activateWindow() @@ -781,6 +785,11 @@ class AnkiConnect: return self.anki.findCards(query) + @webApi + def cardsToNotes(self, cards): + return self.anki.cardsToNotes(cards) + + @webApi def guiBrowse(self, query=None): return self.anki.guiBrowse(query) diff --git a/README.md b/README.md index bd9858c..43b9c63 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,29 @@ 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 + ] + ``` + * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. From aea292f092e580ec58daf8ec835746b2f1caac6a Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 10 Aug 2017 18:34:43 +0100 Subject: [PATCH 16/52] Add changeDeck function --- AnkiConnect.py | 22 ++++++++++++++++++++++ README.md | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index c6f4897..95b1326 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -507,6 +507,23 @@ class AnkiBridge: return [] + def changeDeck(self, cards, deck): + self.startEditing() + + did = self.window().col.decks.id(deck) + mod = anki.utils.intTime() + usn = self.window().col.usn() + + # normal cards + scids = anki.utils.ids2str(cards) + # remove any cards from filtered deck first + self.window().col.sched.remFromDyn(cards) + + # then move into new deck + self.window().col.db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) + self.stopEditing() + + def cardsToNotes(self, cards): return self.window().col.db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) @@ -785,6 +802,11 @@ class AnkiConnect: return self.anki.findCards(query) + @webApi + def changeDeck(self, cards, deck): + return self.anki.changeDeck(cards, deck) + + @webApi def cardsToNotes(self, cards): return self.anki.cardsToNotes(cards) diff --git a/README.md b/README.md index 43b9c63..e728f29 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,26 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` +* **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 + ``` + * **cardsToNotes** Returns an (unordered) array of note IDs for the given card IDs. For cards with the same note, the ID is only From a6c15d7bb20ac099cecf728f05307c4317691fee Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sat, 12 Aug 2017 15:57:28 +0100 Subject: [PATCH 17/52] Change isSuspended to areSuspended; add areDue function; code style fixes --- AnkiConnect.py | 60 ++++++++++++++++++++++++++++++++++++++------------ README.md | 32 ++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 95b1326..5faf6fc 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -24,6 +24,7 @@ import os.path import select import socket import sys +from time import time # @@ -401,18 +402,44 @@ class AnkiBridge: return False - def isSuspended(self, card): - card = self.collection().getCard(card) - if card.queue == -1: - return True - else: - 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: + date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] + + if self.findCards('cid:%s is:new' % card): + due.append(True) + continue + + 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: - interval = self.window().col.db.list('select ivl from revlog where cid = ?', card) + interval = self.collection().db.list('select ivl from revlog where cid = ?', card) if not complete: interval = interval[-1] intervals.append(interval) @@ -510,22 +537,22 @@ class AnkiBridge: def changeDeck(self, cards, deck): self.startEditing() - did = self.window().col.decks.id(deck) + did = self.collection().decks.id(deck) mod = anki.utils.intTime() - usn = self.window().col.usn() + usn = self.collection().usn() # normal cards scids = anki.utils.ids2str(cards) # remove any cards from filtered deck first - self.window().col.sched.remFromDyn(cards) + self.collection().sched.remFromDyn(cards) # then move into new deck - self.window().col.db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) + self.collection().db.execute('update cards set usn=?, mod=?, did=? where id in ' + scids, usn, mod, did) self.stopEditing() def cardsToNotes(self, cards): - return self.window().col.db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) + return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) def guiBrowse(self, query=None): @@ -755,8 +782,13 @@ class AnkiConnect: @webApi - def isSuspended(self, card): - return self.anki.isSuspended(card) + def areSuspended(self, cards): + return self.anki.areSuspended(cards) + + + @webApi + def areDue(self, cards): + return self.anki.areDue(cards) @webApi diff --git a/README.md b/README.md index e728f29..605fdb9 100644 --- a/README.md +++ b/README.md @@ -385,23 +385,45 @@ Below is a list of currently supported actions. Requests with invalid actions or true ``` -* **isSuspended** +* **areSuspended** - Returns `true` if the given card is suspended or `false` otherwise. + Returns an array, where the value at an index is `true` if the card with the given ID at that index is suspended, or + `false` otherwise. *Sample request*: ``` { - "action": "isSuspended", + "action": "areSuspended", "params": { - "card": 1483959291685 + "cards": [1483959291685, 1483959293217] } } ``` *Sample response*: ``` - false + [false, true] + ``` + +* **areDue** + + Returns an array, where the value at an index is `true` if the card with the ID given at that index is due, or + `false` otherwise. 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. + + *Sample request*: + ``` + { + "action": "areDue", + "params": { + "cards": [1483959291685, 1483959293217] + } + } + ``` + + *Sample response*: + ``` + [false, true] ``` * **getIntervals** From 64c61a32fac8320eb63faaeb9348be1ea9434e18 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sat, 12 Aug 2017 16:21:04 +0100 Subject: [PATCH 18/52] Fix IndexError for new cards with areDue / getIntervals --- AnkiConnect.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 5faf6fc..a8dc83b 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -416,12 +416,11 @@ class AnkiBridge: def areDue(self, cards): due = [] for card in cards: - date, ivl = self.collection().db.all('select id/1000.0, ivl from revlog where cid = ?', card)[-1] - 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) @@ -439,6 +438,10 @@ class AnkiBridge: 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] From 3f4885adfbf78fe2b570fadaf711455cdd457661 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sat, 12 Aug 2017 16:28:38 +0100 Subject: [PATCH 19/52] Improve wording of README --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 605fdb9..e82bfa6 100644 --- a/README.md +++ b/README.md @@ -387,8 +387,7 @@ Below is a list of currently supported actions. Requests with invalid actions or * **areSuspended** - Returns an array, where the value at an index is `true` if the card with the given ID at that index is suspended, or - `false` otherwise. + Returns an array indicating whether each of the given cards is suspended (in the same order). *Sample request*: ``` @@ -407,9 +406,9 @@ Below is a list of currently supported actions. Requests with invalid actions or * **areDue** - Returns an array, where the value at an index is `true` if the card with the ID given at that index is due, or - `false` otherwise. 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. + 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*: ``` From 6da8481b07592b4664da3c14065f1c505d01de20 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 13 Aug 2017 09:02:59 +0100 Subject: [PATCH 20/52] Add getDecks function --- AnkiConnect.py | 19 +++++++++++++++++++ README.md | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index a8dc83b..55a99ed 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -537,6 +537,20 @@ class AnkiBridge: 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() @@ -837,6 +851,11 @@ class AnkiConnect: 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) diff --git a/README.md b/README.md index e82bfa6..488da6b 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,30 @@ 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. From 99d70cc2a4fd57066cbc2e41bc8a8b0225e313b7 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 13 Aug 2017 10:05:56 +0100 Subject: [PATCH 21/52] Add deleteDecks function --- AnkiConnect.py | 13 +++++++++++++ README.md | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 55a99ed..09937ec 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -568,6 +568,14 @@ class AnkiBridge: self.stopEditing() + def deleteDecks(self, decks, cardsToo=False): + self.startEditing() + for deck in decks: + id = self.collection().decks.id(deck) + self.collection().decks.rem(id, cardsToo) + self.stopEditing() + + def cardsToNotes(self, cards): return self.collection().db.list('select distinct nid from cards where id in ' + anki.utils.ids2str(cards)) @@ -861,6 +869,11 @@ class AnkiConnect: 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) diff --git a/README.md b/README.md index 488da6b..9d16bfc 100644 --- a/README.md +++ b/README.md @@ -554,6 +554,27 @@ Below is a list of currently supported actions. Requests with invalid actions or ``` 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** From 7a7460785faa37157b8dc7b4d86a4fdf5ae1b5ae Mon Sep 17 00:00:00 2001 From: Charles Henry Date: Mon, 14 Aug 2017 19:30:08 +0100 Subject: [PATCH 22/52] Fix issue with AnkiConnect self update --- AnkiConnect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 09937ec..e70cffb 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -34,7 +34,7 @@ from time import time API_VERSION = 4 TICK_INTERVAL = 25 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_BACKLOG = 5 NET_PORT = 8765 @@ -833,7 +833,7 @@ class AnkiConnect: if response == QMessageBox.Yes: data = download(URL_UPGRADE) 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: path = os.path.splitext(__file__)[0] + '.py' with open(path, 'w') as fp: From 910ca48d838afa357aaf2f22bc123ab791683d77 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Mon, 14 Aug 2017 21:02:56 +0100 Subject: [PATCH 23/52] Fix README typo and trailing spaces --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d16bfc..b4e311e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Below is a list of currently supported actions. Requests with invalid actions or 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*: @@ -533,7 +533,7 @@ Below is a list of currently supported actions. Requests with invalid actions or "Japanese::JLPT N3": [1502298036657, 1502298033753] } ``` - + * **changeDeck** @@ -554,7 +554,7 @@ Below is a list of currently supported actions. Requests with invalid actions or ``` null ``` - + * **deleteDecks** Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within From 82636b024a17009bc76f11b25aa8b1ecbe50c245 Mon Sep 17 00:00:00 2001 From: Charles Henry Date: Wed, 16 Aug 2017 13:04:05 +0100 Subject: [PATCH 24/52] Add guiStartCardTimer function --- AnkiConnect.py | 16 ++++++++++++++++ README.md | 20 ++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index e70cffb..c5c16ce 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -631,6 +631,17 @@ class AnkiBridge: } + def guiStartCardTimer(self): + if not self.guiReviewActive(): + return False + + card = self.reviewer().card + + if card is not None: + card.startTimer() + return True + + def guiShowQuestion(self): if self.guiReviewActive(): self.reviewer()._showQuestion() @@ -894,6 +905,11 @@ class AnkiConnect: return self.anki.guiCurrentCard() + @webApi + def guiStartCardTimer(self): + return self.anki.guiStartCardTimer() + + @webApi def guiAnswerCard(self, ease): return self.anki.guiAnswerCard(ease) diff --git a/README.md b/README.md index 9d16bfc..65ffe30 100644 --- a/README.md +++ b/README.md @@ -533,7 +533,7 @@ Below is a list of currently supported actions. Requests with invalid actions or "Japanese::JLPT N3": [1502298036657, 1502298033753] } ``` - + * **changeDeck** @@ -554,7 +554,7 @@ Below is a list of currently supported actions. Requests with invalid actions or ``` null ``` - + * **deleteDecks** Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within @@ -672,6 +672,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. From c6cad9420c2761c3f66e48dbde87ac3173b1b821 Mon Sep 17 00:00:00 2001 From: Charles Henry Date: Wed, 16 Aug 2017 13:09:48 +0100 Subject: [PATCH 25/52] guiStartCardTimer - ensure consistent output in case of failure --- AnkiConnect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index c5c16ce..c7f478f 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -640,7 +640,8 @@ class AnkiBridge: if card is not None: card.startTimer() return True - + else: + return False def guiShowQuestion(self): if self.guiReviewActive(): From b8c5b7c980af8bb17de116844e7d0ad74775b3e4 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Wed, 16 Aug 2017 22:26:53 +0100 Subject: [PATCH 26/52] Add deckNamesAndIds action --- AnkiConnect.py | 16 ++++++++++++++++ README.md | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index c7f478f..d31859c 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -515,6 +515,17 @@ class AnkiBridge: return collection.decks.allNames() + def deckNamesAndIds(self): + decks = {} + + deckNames = self.deckNames() + for deck in deckNames: + id = self.collection().decks.id(deck) + decks[deck] = id + + return decks + + def deckNameFromId(self, deckId): collection = self.collection() if collection is not None: @@ -753,6 +764,11 @@ class AnkiConnect: return self.anki.deckNames() + @webApi + def deckNamesAndIds(self): + return self.anki.deckNamesAndIds() + + @webApi def modelNames(self): return self.anki.modelNames() diff --git a/README.md b/README.md index e16faf1..d2493c3 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,24 @@ 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 + } + ``` + * **modelNames** Gets the complete list of model names for the current user. From 5ec2d7e33e5a29a73ce2aafcf84f2065b059d86b Mon Sep 17 00:00:00 2001 From: David Bailey Date: Thu, 17 Aug 2017 13:25:06 +0100 Subject: [PATCH 27/52] Add config group actions + Move the multi action to a more sensible position --- AnkiConnect.py | 94 +++++++++++++++++++++++++--- README.md | 166 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 239 insertions(+), 21 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index d31859c..d16ddd4 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -474,6 +474,13 @@ class AnkiBridge: return self.collection().sched + def multi(self, actions): + response = [] + for item in actions: + response.append(AnkiConnect.handler(ac, item)) + return response + + def media(self): collection = self.collection() if collection is not None: @@ -502,11 +509,59 @@ class AnkiBridge: return [field['name'] for field in model['flds']] - def multi(self, actions): - response = [] - for item in actions: - response.append(AnkiConnect.handler(ac, item)) - return response + def confForDeck(self, deck): + if not deck in self.deckNames(): + return False + + id = self.collection().decks.id(deck) + return self.collection().decks.confForDid(id) + + + def saveConf(self, conf): + id = str(conf['id']) + if not id in self.collection().decks.dconf: + return False + + mod = anki.utils.intTime() + usn = self.collection().usn() + + conf['mod'] = mod + conf['usn'] = usn + + self.collection().decks.dconf[id] = conf + self.collection().decks.changed = True + return True + + + def changeConf(self, decks, confId): + for deck in decks: + if not deck in self.deckNames(): + return False + + if not str(confId) 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'] = confId + + return True + + + def addConf(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 remConf(self, id): + if id == 1 or not str(id) in self.collection().decks.dconf: + return False + + self.collection().decks.remConf(id) + return True def deckNames(self): @@ -759,6 +814,11 @@ class AnkiConnect: return handler(**params) + @webApi + def multi(self, actions): + return self.anki.multi(actions) + + @webApi def deckNames(self): return self.anki.deckNames() @@ -780,8 +840,28 @@ class AnkiConnect: @webApi - def multi(self, actions): - return self.anki.multi(actions) + def confForDeck(self, deck): + return self.anki.confForDeck(deck) + + + @webApi + def saveConf(self, conf): + return self.anki.saveConf(conf) + + + @webApi + def changeConf(self, decks, confId): + return self.anki.changeConf(decks, confId) + + + @webApi + def addConf(self, name, cloneFrom=1): + return self.anki.addConf(name, cloneFrom) + + + @webApi + def remConf(self, id): + return self.anki.remConf(id) @webApi diff --git a/README.md b/README.md index d2493c3..f36c6ed 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,35 @@ Below is a list of currently supported actions. Requests with invalid actions or ``` 4 ``` + +* **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] + ] + ``` + * **deckNames** Gets the complete list of deck names for the current user. @@ -180,32 +209,141 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` -* **multi** +* **confForDeck** - Performs multiple actions in one request, returning an array with the response of each action (in the given order). + Gets the config group object for the given deck. *Sample request*: ``` { - "action": "multi", + "action": "confForDeck", "params": { - "actions": [ - {"action": "deckNames"}, - { - "action": "browse", - "params": {"query": "deck:current"} - } - ] + "deck": "Default" } } ``` *Sample response*: ``` - [ - ["Default"], - [1494723142483, 1494703460437, 1494703479525] - ] + { + "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 + } + ``` + +* **saveConf** + + 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": "saveConf", + "params": { + "conf": (config group object) + } + } + ``` + + *Sample response*: + ``` + true + ``` + +* **changeConf** + + 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": "changeConf", + "params": { + "decks": ["Default"], + "confId": 1 + } + } + ``` + + *Sample response*: + ``` + true + ``` + +* **addConf** + + 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": "addConf", + "params": { + "name": "Copy of Default", + "cloneFrom": 1 + } + } + ``` + + *Sample response*: + ``` + 1502972374573 + ``` + +* **remConf** + + 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": "remConf", + "params": { + "id": 1502972374573 + } + } + ``` + + *Sample response*: + ``` + true ``` * **addNote** From a9f9acf85f490fa6b021410d2f59a69b665fa7f8 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Thu, 17 Aug 2017 21:22:15 -0700 Subject: [PATCH 28/52] Updating README.md --- README.md | 216 ++---------------------------------------------------- 1 file changed, 6 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index d2493c3..980f4fe 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Below is a list of currently supported actions. Requests with invalid actions or 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 are backwards compatible; as long as you are using actions + properly with each other. New versions of AnkiConnect will 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*: @@ -121,24 +121,6 @@ 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 - } - ``` - * **modelNames** Gets the complete list of model names for the current user. @@ -365,8 +347,7 @@ Below is a list of currently supported actions. Requests with invalid actions or * **suspend** - Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` - otherwise. + Suspend cards by card ID. *Sample request*: ``` @@ -380,13 +361,12 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - true + null ``` * **unsuspend** - Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false` - otherwise. + Unsuspend cards by card ID. *Sample request*: ``` @@ -400,89 +380,9 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - true + null ``` -* **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] - ``` - -* **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] - ] - ``` - - * **findNotes** Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). @@ -490,7 +390,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "findNotes", + "action": "findCards", "params": { "query": "deck:current" } @@ -529,94 +429,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 - given once in the array. - - *Sample request*: - ``` - { - "action": "cardsToNotes", - "params": { - "cards": [1502098034045, 1502098034048, 1502298033753] - } - } - ``` - - *Sample response*: - ``` - [ - 1502098029797, - 1502298025183 - ] - ``` - * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. @@ -690,22 +502,6 @@ 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. From 880ff722bb578ec6d0fa97aa48bc9853141f9035 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Thu, 17 Aug 2017 21:23:56 -0700 Subject: [PATCH 29/52] Revert "Updating README.md" This reverts commit a9f9acf85f490fa6b021410d2f59a69b665fa7f8. --- README.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 980f4fe..d2493c3 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Below is a list of currently supported actions. Requests with invalid actions or 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*: @@ -121,6 +121,24 @@ 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 + } + ``` + * **modelNames** Gets the complete list of model names for the current user. @@ -347,7 +365,8 @@ Below is a list of currently supported actions. Requests with invalid actions or * **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*: ``` @@ -361,12 +380,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*: ``` @@ -380,9 +400,89 @@ 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] + ``` + +* **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] + ] + ``` + + * **findNotes** Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). @@ -390,7 +490,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "findCards", + "action": "findNotes", "params": { "query": "deck:current" } @@ -429,6 +529,94 @@ 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 + given once in the array. + + *Sample request*: + ``` + { + "action": "cardsToNotes", + "params": { + "cards": [1502098034045, 1502098034048, 1502298033753] + } + } + ``` + + *Sample response*: + ``` + [ + 1502098029797, + 1502298025183 + ] + ``` + * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. @@ -502,6 +690,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. From 81d49c3cd886fc296b42939f9e03d2d2cae756ff Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 16:15:08 +0100 Subject: [PATCH 30/52] Fix broken guiCurrentCard A map can't be converted to JSON --- AnkiConnect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index d31859c..f9061e7 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -636,7 +636,7 @@ class AnkiBridge: 'fieldOrder': card.ord, 'question': card._getQA()['q'], 'answer': card._getQA()['a'], - 'buttons': map(lambda b: b[0], reviewer._answerButtonList()), + 'buttons': [b[0] for b in reviewer._answerButtonList()], 'modelName': model['name'], 'deckName': self.deckNameFromId(card.did) } From ca6d513470458c2130a9182f266629298454c9c4 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 20:37:46 +0100 Subject: [PATCH 31/52] Change id variables to did / configId --- AnkiConnect.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index d16ddd4..b2f13bb 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -513,13 +513,13 @@ class AnkiBridge: if not deck in self.deckNames(): return False - id = self.collection().decks.id(deck) - return self.collection().decks.confForDid(id) + did = self.collection().decks.id(deck) + return self.collection().decks.confForDid(did) def saveConf(self, conf): - id = str(conf['id']) - if not id in self.collection().decks.dconf: + confId = str(conf['id']) + if not confId in self.collection().decks.dconf: return False mod = anki.utils.intTime() @@ -528,7 +528,7 @@ class AnkiBridge: conf['mod'] = mod conf['usn'] = usn - self.collection().decks.dconf[id] = conf + self.collection().decks.dconf[confId] = conf self.collection().decks.changed = True return True @@ -556,11 +556,11 @@ class AnkiBridge: return self.collection().decks.confId(name, cloneFrom) - def remConf(self, id): - if id == 1 or not str(id) in self.collection().decks.dconf: + def remConf(self, configId): + if configId == 1 or not str(configId) in self.collection().decks.dconf: return False - self.collection().decks.remConf(id) + self.collection().decks.remConf(configId) return True @@ -575,8 +575,8 @@ class AnkiBridge: deckNames = self.deckNames() for deck in deckNames: - id = self.collection().decks.id(deck) - decks[deck] = id + did = self.collection().decks.id(deck) + decks[deck] = did return decks @@ -637,8 +637,8 @@ class AnkiBridge: def deleteDecks(self, decks, cardsToo=False): self.startEditing() for deck in decks: - id = self.collection().decks.id(deck) - self.collection().decks.rem(id, cardsToo) + did = self.collection().decks.id(deck) + self.collection().decks.rem(did, cardsToo) self.stopEditing() @@ -860,8 +860,8 @@ class AnkiConnect: @webApi - def remConf(self, id): - return self.anki.remConf(id) + def remConf(self, configId): + return self.anki.remConf(configId) @webApi From e2c9eaaa3bd80c7c5df5a8ee5d071b8f0c9ddba0 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 20:54:32 +0100 Subject: [PATCH 32/52] Rename actions --- AnkiConnect.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index b2f13bb..d00fa23 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -509,7 +509,7 @@ class AnkiBridge: return [field['name'] for field in model['flds']] - def confForDeck(self, deck): + def getDeckConfig(self, deck): if not deck in self.deckNames(): return False @@ -517,7 +517,7 @@ class AnkiBridge: return self.collection().decks.confForDid(did) - def saveConf(self, conf): + def saveDeckConfig(self, conf): confId = str(conf['id']) if not confId in self.collection().decks.dconf: return False @@ -533,7 +533,7 @@ class AnkiBridge: return True - def changeConf(self, decks, confId): + def setDeckConfigId(self, decks, confId): for deck in decks: if not deck in self.deckNames(): return False @@ -548,7 +548,7 @@ class AnkiBridge: return True - def addConf(self, name, cloneFrom=1): + def cloneDeckConfigId(self, name, cloneFrom=1): if not str(cloneFrom) in self.collection().decks.dconf: return False @@ -556,7 +556,7 @@ class AnkiBridge: return self.collection().decks.confId(name, cloneFrom) - def remConf(self, configId): + def removeDeckConfigId(self, configId): if configId == 1 or not str(configId) in self.collection().decks.dconf: return False @@ -840,28 +840,28 @@ class AnkiConnect: @webApi - def confForDeck(self, deck): - return self.anki.confForDeck(deck) + def getDeckConfig(self, deck): + return self.anki.getDeckConfig(deck) @webApi - def saveConf(self, conf): - return self.anki.saveConf(conf) + def saveDeckConfig(self, conf): + return self.anki.saveDeckConfig(conf) @webApi - def changeConf(self, decks, confId): - return self.anki.changeConf(decks, confId) + def setDeckConfigId(self, decks, confId): + return self.anki.setDeckConfigId(decks, confId) @webApi - def addConf(self, name, cloneFrom=1): - return self.anki.addConf(name, cloneFrom) + def cloneDeckConfigId(self, name, cloneFrom=1): + return self.anki.cloneDeckConfigId(name, cloneFrom) @webApi - def remConf(self, configId): - return self.anki.remConf(configId) + def removeDeckConfigId(self, configId): + return self.anki.removeDeckConfigId(configId) @webApi From f980758207aed6bbf614fdc9518a0aabf65b9a65 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 20:57:19 +0100 Subject: [PATCH 33/52] Rename actions in README --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f36c6ed..9447453 100644 --- a/README.md +++ b/README.md @@ -209,14 +209,14 @@ Below is a list of currently supported actions. Requests with invalid actions or ] ``` -* **confForDeck** +* **getDeckConfig** Gets the config group object for the given deck. *Sample request*: ``` { - "action": "confForDeck", + "action": "getDeckConfig", "params": { "deck": "Default" } @@ -263,7 +263,7 @@ Below is a list of currently supported actions. Requests with invalid actions or } ``` -* **saveConf** +* **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). @@ -271,7 +271,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "saveConf", + "action": "saveDeckConfig", "params": { "conf": (config group object) } @@ -283,7 +283,7 @@ Below is a list of currently supported actions. Requests with invalid actions or true ``` -* **changeConf** +* **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. @@ -291,7 +291,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "changeConf", + "action": "setDeckConfigId", "params": { "decks": ["Default"], "confId": 1 @@ -304,7 +304,7 @@ Below is a list of currently supported actions. Requests with invalid actions or true ``` -* **addConf** +* **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 @@ -313,7 +313,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "addConf", + "action": "cloneDeckConfigId", "params": { "name": "Copy of Default", "cloneFrom": 1 @@ -326,7 +326,7 @@ Below is a list of currently supported actions. Requests with invalid actions or 1502972374573 ``` -* **remConf** +* **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. @@ -334,7 +334,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "remConf", + "action": "removeDeckConfigId", "params": { "id": 1502972374573 } From 933c7de6b977db6434a303c707895b64a3b601a2 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 21:09:28 +0100 Subject: [PATCH 34/52] confId => configId --- AnkiConnect.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index d00fa23..ef01195 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -518,8 +518,8 @@ class AnkiBridge: def saveDeckConfig(self, conf): - confId = str(conf['id']) - if not confId in self.collection().decks.dconf: + configId = str(conf['id']) + if not configId in self.collection().decks.dconf: return False mod = anki.utils.intTime() @@ -528,22 +528,22 @@ class AnkiBridge: conf['mod'] = mod conf['usn'] = usn - self.collection().decks.dconf[confId] = conf + self.collection().decks.dconf[configId] = conf self.collection().decks.changed = True return True - def setDeckConfigId(self, decks, confId): + def setDeckConfigId(self, decks, configId): for deck in decks: if not deck in self.deckNames(): return False - if not str(confId) in self.collection().decks.dconf: + 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'] = confId + aqt.mw.col.decks.decks[did]['conf'] = configId return True @@ -850,8 +850,8 @@ class AnkiConnect: @webApi - def setDeckConfigId(self, decks, confId): - return self.anki.setDeckConfigId(decks, confId) + def setDeckConfigId(self, decks, configId): + return self.anki.setDeckConfigId(decks, configId) @webApi From 33908a62973839ffadeae0ec3f472929770ff339 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 21:10:51 +0100 Subject: [PATCH 35/52] Update README (id => configId) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9447453..02ef413 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Below is a list of currently supported actions. Requests with invalid actions or "action": "setDeckConfigId", "params": { "decks": ["Default"], - "confId": 1 + "configId": 1 } } ``` @@ -336,7 +336,7 @@ Below is a list of currently supported actions. Requests with invalid actions or { "action": "removeDeckConfigId", "params": { - "id": 1502972374573 + "configId": 1502972374573 } } ``` From b6ab724dd718cda0d6e7122090cda30d2233ddec Mon Sep 17 00:00:00 2001 From: David Bailey Date: Fri, 18 Aug 2017 21:16:50 +0100 Subject: [PATCH 36/52] conf => config in saveDeckConfig --- AnkiConnect.py | 14 +++++++------- README.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index ef01195..1d6d93e 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -517,18 +517,18 @@ class AnkiBridge: return self.collection().decks.confForDid(did) - def saveDeckConfig(self, conf): - configId = str(conf['id']) + 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() - conf['mod'] = mod - conf['usn'] = usn + config['mod'] = mod + config['usn'] = usn - self.collection().decks.dconf[configId] = conf + self.collection().decks.dconf[configId] = config self.collection().decks.changed = True return True @@ -845,8 +845,8 @@ class AnkiConnect: @webApi - def saveDeckConfig(self, conf): - return self.anki.saveDeckConfig(conf) + def saveDeckConfig(self, config): + return self.anki.saveDeckConfig(config) @webApi diff --git a/README.md b/README.md index 02ef413..1f35bcc 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ Below is a list of currently supported actions. Requests with invalid actions or { "action": "saveDeckConfig", "params": { - "conf": (config group object) + "config": (config group object) } } ``` From bca361d79ada38c29af15313462c631b3824d317 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sat, 19 Aug 2017 11:38:55 -0700 Subject: [PATCH 37/52] Updating README.md --- README.md | 216 ++---------------------------------------------------- 1 file changed, 6 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index d2493c3..980f4fe 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Below is a list of currently supported actions. Requests with invalid actions or 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 are backwards compatible; as long as you are using actions + properly with each other. New versions of AnkiConnect will 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*: @@ -121,24 +121,6 @@ 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 - } - ``` - * **modelNames** Gets the complete list of model names for the current user. @@ -365,8 +347,7 @@ Below is a list of currently supported actions. Requests with invalid actions or * **suspend** - Suspend cards by card ID; returns `true` if successful (at least one card wasn't already suspended) or `false` - otherwise. + Suspend cards by card ID. *Sample request*: ``` @@ -380,13 +361,12 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - true + null ``` * **unsuspend** - Unsuspend cards by card ID; returns `true` if successful (at least one card was previously suspended) or `false` - otherwise. + Unsuspend cards by card ID. *Sample request*: ``` @@ -400,89 +380,9 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample response*: ``` - true + null ``` -* **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] - ``` - -* **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] - ] - ``` - - * **findNotes** Returns an array of note IDs for a given query (same query syntax as **guiBrowse**). @@ -490,7 +390,7 @@ Below is a list of currently supported actions. Requests with invalid actions or *Sample request*: ``` { - "action": "findNotes", + "action": "findCards", "params": { "query": "deck:current" } @@ -529,94 +429,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 - given once in the array. - - *Sample request*: - ``` - { - "action": "cardsToNotes", - "params": { - "cards": [1502098034045, 1502098034048, 1502298033753] - } - } - ``` - - *Sample response*: - ``` - [ - 1502098029797, - 1502298025183 - ] - ``` - * **guiBrowse** Invokes the card browser and searches for a given query. Returns an array of identifiers of the cards that were found. @@ -690,22 +502,6 @@ 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. From 658763dd5baf5088fa6635083a7377e70d4b6ab2 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sat, 19 Aug 2017 23:24:53 +0100 Subject: [PATCH 38/52] Allow other add-ons to add headers --- AnkiConnect.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index 9fbe5a0..a4acd20 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -199,6 +199,19 @@ class AjaxServer: self.handler = handler self.clients = [] self.sock = None + self.resetHeaders() + + + def addHeader(self, name, value): + self.headers.append([name, value]) + + + def resetHeaders(self): + self.headers = [ + ['HTTP/1.1 200 OK', None], + ['Content-Type', 'text/json'], + ['Content-Length', ''] + ] def advance(self): @@ -243,11 +256,9 @@ class AjaxServer: body = json.dumps(None); resp = bytes() - headers = [ - ['HTTP/1.1 200 OK', None], - ['Content-Type', 'text/json'], - ['Content-Length', str(len(body))] - ] + + headers = self.headers + headers[2][1] = str(len(body)) for key, value in headers: if value is None: From 8179643067745763563f819cb615bffb73cdcf82 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 20 Aug 2017 20:32:16 +0100 Subject: [PATCH 39/52] Improve handling of HTTP headers --- AnkiConnect.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index a4acd20..ac1cfff 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -202,16 +202,22 @@ class AjaxServer: self.resetHeaders() - def addHeader(self, name, value): - self.headers.append([name, value]) - - + def setHeader(self, name, value): + self.headers[name] = value + + def resetHeaders(self): - self.headers = [ - ['HTTP/1.1 200 OK', None], - ['Content-Type', 'text/json'], - ['Content-Length', ''] - ] + self.headers = { + 'HTTP/1.1 200 OK': None, + 'Content-Type': 'text/json' + } + + + def getHeaders(self): + headers = [] + for name in self.headers: + headers.append([name, self.headers[name]]) + return headers def advance(self): @@ -256,9 +262,9 @@ class AjaxServer: body = json.dumps(None); resp = bytes() - - headers = self.headers - headers[2][1] = str(len(body)) + + self.setHeader('Content-Length', str(len(body))) + headers = self.getHeaders() for key, value in headers: if value is None: From d86722c3cb0ce1599785d4ab19a33443dac4d0ee Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 20 Aug 2017 22:29:52 +0100 Subject: [PATCH 40/52] Separate standard headers from extra headers --- AnkiConnect.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index ac1cfff..c8b3c2b 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -203,19 +203,20 @@ class AjaxServer: def setHeader(self, name, value): - self.headers[name] = value + self.extraHeaders[name] = value def resetHeaders(self): - self.headers = { - 'HTTP/1.1 200 OK': None, - 'Content-Type': 'text/json' - } + self.headers = [ + ['HTTP/1.1 200 OK', None], + ['Content-Type', 'text/json'] + ] + self.extraHeaders = {} def getHeaders(self): - headers = [] - for name in self.headers: + headers = self.headers + for name in self.extraHeaders: headers.append([name, self.headers[name]]) return headers From 0cc83ab161cf243507b46ee0b55dee800dceddcd Mon Sep 17 00:00:00 2001 From: David Bailey Date: Sun, 20 Aug 2017 22:39:50 +0100 Subject: [PATCH 41/52] Bugfix --- AnkiConnect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index c8b3c2b..8dececb 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -215,9 +215,9 @@ class AjaxServer: def getHeaders(self): - headers = self.headers + headers = self.headers[:] for name in self.extraHeaders: - headers.append([name, self.headers[name]]) + headers.append([name, self.extraHeaders[name]]) return headers From 75a3a25e91df16cfb3408304adbae43aad59b619 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Mon, 21 Aug 2017 15:10:57 +0100 Subject: [PATCH 42/52] Add modelFieldsOnTemplates action --- AnkiConnect.py | 37 +++++++++++++++++++++++++++++++++++++ README.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 8dececb..391e6ce 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -21,6 +21,7 @@ import hashlib import inspect import json import os.path +import re import select import socket import sys @@ -527,6 +528,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 @@ -857,6 +889,11 @@ class AnkiConnect: 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 1f35bcc..8d2c1fc 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,35 @@ 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"] + ] + } + ``` + * **getDeckConfig** Gets the config group object for the given deck. From 2e09db9dab2242f69c1979fdd5f19e9c651ba869 Mon Sep 17 00:00:00 2001 From: David Bailey Date: Mon, 21 Aug 2017 17:15:11 +0100 Subject: [PATCH 43/52] Add modelNamesAndIds action --- AnkiConnect.py | 17 +++++++++++++++++ README.md | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index 391e6ce..036e935 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -512,6 +512,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: @@ -884,6 +896,11 @@ class AnkiConnect: return self.anki.modelNames() + @webApi + def modelNamesAndIds(self): + return self.anki.modelNamesAndIds() + + @webApi def modelFieldNames(self, modelName): return self.anki.modelFieldNames(modelName) diff --git a/README.md b/README.md index 8d2c1fc..05320b5 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,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** Gets the complete list of field names for the provided model name. From d64643baa6b00783275765a0789b8e60480090af Mon Sep 17 00:00:00 2001 From: David Bailey Date: Tue, 22 Aug 2017 08:53:35 +0100 Subject: [PATCH 44/52] 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 45/52] 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 46/52] 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 47/52] 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 48/52] 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 ### From b2f4d54ac2afba3ba1519c85421850819154ae29 Mon Sep 17 00:00:00 2001 From: tomasgodoi Date: Mon, 28 Aug 2017 17:15:42 -0300 Subject: [PATCH 49/52] Allowing to bind on other IP Addresses through an environment variable. Writing the corresponding documentation. --- AnkiConnect.py | 3 ++- README.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index b5f2774..3a05908 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -21,6 +21,7 @@ import base64 import hashlib import inspect import json +import os import os.path import re import select @@ -38,7 +39,7 @@ API_VERSION = 4 TICK_INTERVAL = 25 URL_TIMEOUT = 10 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_PORT = 8765 diff --git a/README.md b/README.md index 219a110..b5c0f8e 100644 --- a/README.md +++ b/README.md @@ -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 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 ### Every request consists of a JSON-encoded object containing an *action*, and a set of contextual *parameters*. A simple From 4787641c7bd0dc4340012621d2fff38b3ba5d50b Mon Sep 17 00:00:00 2001 From: tomasgodoi Date: Mon, 28 Aug 2017 17:49:58 -0300 Subject: [PATCH 50/52] Making the incoming headers case insensitive. Any reference to incoming headers should now be made with lowercase. --- AnkiConnect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiConnect.py b/AnkiConnect.py index b5f2774..4d151c3 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -180,10 +180,10 @@ class AjaxClient: headers = {} for line in parts[0].split(makeBytes('\r\n')): 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 - bodyLength = int(headers.get(makeBytes('Content-Length'), 0)) + bodyLength = int(headers.get(makeBytes('content-length'), 0)) totalLength = headerLength + bodyLength if totalLength > len(data): From a35313922d347794c5111fc7dd7b874c4c0f836b Mon Sep 17 00:00:00 2001 From: tomasgodoi Date: Mon, 28 Aug 2017 23:24:08 -0300 Subject: [PATCH 51/52] Creating the guiExitAnki endpoint to allow closing Anki programmatically. --- AnkiConnect.py | 11 +++++++++++ README.md | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/AnkiConnect.py b/AnkiConnect.py index ace378d..7659bde 100644 --- a/AnkiConnect.py +++ b/AnkiConnect.py @@ -850,6 +850,13 @@ class AnkiBridge: else: 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 @@ -1149,6 +1156,10 @@ class AnkiConnect: def guiDeckReview(self, name): return self.anki.guiDeckReview(name) + @webApi + def guiExitAnki(self): + return self.anki.guiExitAnki() + # # Entry diff --git a/README.md b/README.md index b5c0f8e..4a009f7 100644 --- a/README.md +++ b/README.md @@ -1119,6 +1119,21 @@ Categories: ``` true ``` +* **guiExitAnki** + + Schedules a request to close Anki after 1s. This operation is asynchronous, so it will return immediately. + + *Sample request*: + ``` + { + "action": "guiExitAnki" + } + ``` + + *Sample response*: + ``` + null + ``` ## License ## From 0adc2e784452bcc39df2a0c201f82f411459fe59 Mon Sep 17 00:00:00 2001 From: tomasgodoi Date: Mon, 28 Aug 2017 23:27:28 -0300 Subject: [PATCH 52/52] Updating readme. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a009f7..1571167 100644 --- a/README.md +++ b/README.md @@ -1121,7 +1121,7 @@ Categories: ``` * **guiExitAnki** - Schedules a request to close Anki after 1s. This operation is asynchronous, so it will return immediately. + 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. *Sample request*: ```