Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can a plug-in enhance Anki's JavaScript?

Anki enables cards to use JavaScript. For example, a card can contain something like:

<script>
//JavaScript code here
</script>

and the JavaScript code will be executed when the card is shown.

In order to allow more flexibility by enabling such scripts to interact with the Anki back-end (for example in order to change the values of the note's fields, to add tags, to affect the scheduling etc), I would like to write a plug-in for Anki (version 2) that would implement some back-end functions and enable a card's JavaScript script to invoke them.

For example, say I have a (Python) function in my plug-in that interacts with Anki's objects:

def myFunc():
# use plug-in's ability to interact with Anki's objects to do stuff

I want to be able to allow cards' JavaScript to invoke that function, for example to have something like this in a card:

<script>
myFunc(); // This should invoke the plug-in's myFunc().
</script>

I know how to add hooks so that various Anki events invoke my plug-in's functions, but I want to allow the JavaScript from within a card to do so. Can this at all be done, and if so then how? Thanks!

like image 563
Tom Avatar asked Apr 20 '14 13:04

Tom


1 Answers

Having read the post linked to by @Louis, and discussed the issue with some colleagues, and messed around trying various things out, I've finally managed to come up with a solution:

The idea can be summarised in these two key points (and two sub-key points):

  • The plug-in can create one or more objects that will be "exposed" to the cards' JavaScript scripts, so that card scripts can access these objects - their fields and methods - as if they were part of the scripts' scope.

    • in order to do it the objects must be instances of a specific class (or subclass thereof), and each method and property that is to be exposed to card scripts must be declared as such with a proper PyQt decorator.

and

  • PyQt provides the functionality to "inject" such objects to a webview.

    • The plug-in has to ensure this injection occurs every time Anki's reviewer's webview is (re-)initialised.

The following code shows how to acheive this. It provides card scripts with a way to check the current state ("question" or "answer") and with a way to access (read, and - more importantly - write) the note's fields.

from aqt import mw              # Anki's main window object
from aqt import mw QObject      # Our exposed object will be an instance of a subclass of QObject.
from aqt import mw pyqtSlot     # a decorator for exposed methods
from aqt import mw pyqtProperty # a decorator for exposed properties

from anki.hooks import wrap     # We will need this to hook to specific Anki functions in order to make sure the injection happens in time.

# a class whose instance(s) we can expose to card scripts
class CardScriptObject(QObject):
    # some "private" fields - card scripts cannot access these directly 
    _state = None
    _card = None
    _note = None

    # Using pyqtProperty we create a property accessible from the card script.
    # We have to provide the type of the property (in this case str).
    # The second argument is a getter method.
    # This property is read-only. To make it writeable we would add a setter method as a third argument.
    state = pyqtProperty(str, lambda self: self._state)

    # The following methods are exposed to the card script owing to the pyqtSlot decorator.
    # Without it they would be "private".
    @pyqtSlot(str, result = str) # We have to provide the argument type(s) (excluding self),
                                 # as well as the type of the return value - with the named result argument, if a value is to be returned.
    def getField(self, name):
        return self._note[name]

    # Another method, without a return value:
    @pyqtSlot(str, str)
    def setField(self, name, value):
        self._note[name] = value
        self._note.flush()

    # An example of a method that can be invoked with two different signatures -
    # pyqtSlot has to be used for each possible signature:
    # (This method replaces the above two.
    # All three have been included here for the sake of the example.)
    @pyqtSlot(str, result = str)
    @pyqtSlot(str, str)
    def field(self, name, value = None): # sets a field if value given, gets a field otherwise
        if value is None: return self._note[name]
        self._note[name] = value
        self._note.flush()

cardScriptObject = CardScriptObject() # the object to expose to card scripts
flag = None # This flag is used in the injection process, which follows.

# This is a hook to Anki's reviewer's _initWeb method.
# It lets the plug-in know the reviewer's webview is being initialised.
# (It would be too early to perform the injection here, as this method is called before the webview is initialised.
# And it would be too late to do it after _initWeb, as the first card would have already been shown.
# Hence this mechanism.)
def _initWeb():
    global flag
    flag = True

# This is a hook to Anki's reviewer's _showQuestion method.
# It populates our cardScriptObject's "private" fields with the relevant values,
# and more importantly, it exposes ("injects") the object to the webview's JavaScript scope -
# but only if this is the first card since the last initialisation, otherwise the object is already exposed.
def _showQuestion():
    global cardScriptObject, flag
    if flag:
        flag = False
        # The following line does the injection.
        # In this example our cardScriptObject will be accessible from card scripts
        # using the name pluginObject.
        mw.web.page().mainFrame().addToJavaScriptWindowObject("pluginObject", cardScriptObject)
    cardScriptObject._state = "question"
    cardScriptObject._card = mw.reviewer.card
    cardScriptObject._note = mw.reviewer.card.note()

# The following hook to Anki's reviewer's _showAnswer is not necessary for the injection,
# but in this example it serves to update the state.
def _showAnswer():
    global cardScriptObject
    cardScriptObject._state = "answer"

# adding our hooks
# In order to already have our object injected when the first card is shown (so that its scripts can "enjoy" this plug-in),
# and in order for the card scripts to have access to up-to-date information,
# our hooks must be executed _before_ the relevant Anki methods.
mw.reviewer._initWeb = wrap(mw.reviewer._initWeb, _initWeb, "before")
mw.reviewer._showQuestion = wrap(mw.reviewer._showQuestion, _showQuestion, "before")
mw.reviewer._showAnswer = wrap(mw.reviewer._showAnswer, _showAnswer, "before")

This is it! With such a plug-in installed a JavaScript script from within a card can use pluginObject.state to check whether it is run as part of the question or as part of the answer (could also be acheived by wrapping the question part in the answer template with a script that sets a variable, but this is neater), pluginObject.field(name) to get the value of a field from the note (could also be acheived by injecting the field directly into the JavaScript code with Anki's pre-processor) and pluginObject.field(name, value) to set the value of a field in the note (couldn't be done till now, as far as I know). Of course, many other pieces of functionality could be programmed into our CardScriptObject to allow card scripts to do much more (read/change configuration, implement another question/answer mechanism, interact with the scheduler, etc...).

If anybody can suggest improvements I'd be interested to hear. Specifically, I'm interested in:

  • whether there is a neater way to expose methods and properties, so as to allow more signature flexibility; and
  • whether there is a less cumbersome way to perform the injection.
like image 72
Tom Avatar answered Oct 18 '22 03:10

Tom