如何通过插件增强Anki的JavaScript?

14 投票
1 回答
3390 浏览
提问于 2025-04-18 03:29

Anki 允许卡片使用 JavaScript,也就是说,你可以在卡片里放一些 JavaScript 代码。例如,卡片里可以包含这样的内容:

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

当你查看这张卡片时,里面的 JavaScript 代码就会被执行。

为了让这些脚本能更灵活地和 Anki 的后台进行互动(比如修改笔记的字段值、添加标签、影响复习计划等等),我想为 Anki(版本 2)写一个插件,这个插件可以实现一些后台功能,并让卡片里的 JavaScript 脚本调用这些功能。

比如说,我在我的插件里有一个(Python)函数,它可以和 Anki 的对象进行互动:

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

我希望能让卡片里的 JavaScript 调用这个函数,比如在卡片里有这样的内容:

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

我知道怎么添加钩子,让 Anki 的各种事件调用我插件里的函数,但我想让卡片里的 JavaScript 也能做到这一点。这能实现吗?如果可以的话,应该怎么做呢?谢谢!

1 个回答

13

我看了@Louis分享的那篇文章,并和一些同事讨论了这个问题,还尝试了各种方法,最后终于找到了解决方案:

这个想法可以总结为两个关键点(还有两个小点):

  • 这个插件可以创建一个或多个对象,这些对象会被“暴露”给卡片的JavaScript脚本,这样卡片脚本就可以像访问自己范围内的内容一样访问这些对象的字段和方法。

    • 为了做到这一点,这些对象必须是特定类(或其子类)的实例,并且每个要暴露给卡片脚本的方法和属性都必须用合适的PyQt装饰器声明。

还有

  • PyQt提供了将这些对象“注入”到网页视图中的功能。

    • 插件必须确保每次Anki的复习者网页视图被(重新)初始化时,这个注入都会发生。

下面的代码展示了如何实现这一点。它为卡片脚本提供了一种检查当前状态(“问题”或“答案”)的方法,以及访问(读取和更重要的,写入)笔记字段的方法。

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")

就这样!安装了这样的插件后,卡片中的JavaScript脚本可以使用pluginObject.state来检查它是作为问题的一部分运行还是作为答案的一部分运行(也可以通过在答案模板中用一个设置变量的脚本包裹问题部分来实现,但这样更整洁),使用pluginObject.field(name)来获取笔记中某个字段的值(也可以通过将字段直接注入到JavaScript代码中来实现),以及使用pluginObject.field(name, value)来设置笔记中某个字段的值(据我所知,这在此之前是做不到的)。当然,我们的CardScriptObject还可以编程实现许多其他功能,让卡片脚本做更多事情(读取/更改配置、实现其他问题/答案机制、与调度器互动等等)。

如果有人能提出改进建议,我很感兴趣。具体来说,我想知道:

  • 是否有更简洁的方法来暴露方法和属性,以便允许更多的签名灵活性;
  • 是否有更简单的方法来进行注入。

撰写回答