はじめに

概要

Anki の UI は、主に Python/PyQt で記述されています。レビューや編集用の画面など、多くの画面は Python/PyQt で書かれています。 画面やエディターには、TypeScript や Svelte も使われています。アドオンを書くには基本的なプログラミングの経験と、Pythonに精通していることが必要です。Python チュートリアル が参考になります。

Anki のアドオンは Python モジュールとして実装され、Anki は起動時にこれを読み込みます。 アドオンは、特定のアクションが発生したときに通知されるように登録することができます。例えば、ブラウズ画面がロードされたときに実行されるフックを登録したり、特定アクションが実行された際に UI に変化を加えることができます(例: 新しいメニューの UI を追加等)。

Anki のアーキテクチャの概要はこちら を参照してください。

普通のテキストエディタで Anki のアドオンを開発することも可能ですが、コードエディターや IDE を使えば、開発がより楽になります。 詳しくは下記の「IDE と 型ヒント」のセクションをご覧ください。

サポート

この文書には、始めるためのヒントがいくつか含まれていますが、包括的なガイドではありません。実際にアドオンを作成するには、Anki のソースコードや、以下のソースコードに精通する必要があります。 あなたが達成しようとしていることと似たようなことを行う他のアドオンです。

私たちのリソースには限りがあるため、アドオンの作成に関する公式なサポートはありません。質問がある場合は、ソースコードから自分で答えを見つけるか、開発フォーラム に質問を投稿する必要があります。

また、アドオンフォーラムを利用して、誰かにアドオンの作成を依頼することもできます。誰かがあなたの手助けをすることに興味を持つ前に、いくらか開発費を提供する必要があるかもしれません。

エディターの設定

メモ帳などの基本的なテキストエディタでアドオンを書くこともできますが、適切な Python エディタ/開発環境(IDE)をセットアップすることで、かなり楽になります。

PyCharm のセットアップ

PyCharm の無償コミュニティ版では、Python のサポートが充実しています: https://www.jetbrains.com/Pycharm/。Visual Studio Code のような他のエディタも使えますが、PyCharm が最も良い結果を出すことが分かっています。

昨年、Anki のコードベースは更新され、ほぼすべてのコードにタイプヒントが追加されました。これらの型ヒントは、より良いコード補完を提供し、mypy などのツールを使用してエラーを検出することで、開発を容易にします。アドオン作者として、あなたもこのタイプヒントを活用することができます。

最初のアドオンを始めるには、以下の流れで進めます:

  • PyCharm を起動し、新規プロジェクトを作成します。

  • 左側のプロジェクトを右クリック/ctrl+クリックし、"myaddon " という Python パッケージを新規に作成します。

ここで、Anki にバンドルされているソースコードを取得し、型式補完ができるようにする必要があります。Anki 2.1.24 は現在、PyPI で入手可能です。Python の 64 ビット版を使用する必要があり、Python のバージョンは取得する Anki のバージョンがサポートするバージョンと一致する必要があります。 Anki を PyCharm 経由でインストールするには、左下の Python Console をクリックし、次のように入力します。

import subprocess

subprocess.check_call(["pip3", "install", "--upgrade", "pip"])
subprocess.check_call(["pip3", "install", "mypy", "aqt"])

エンターキーを押して待ちます。完了したら、コード補完ができるはずです。

エラーが出る場合は、Pythonの64ビット版を使用していないか、Python のバージョンがAnki の最新版でサポートされていない可能性があります。上記のコマンドを「-vvv」付きで実行すると、より詳細な情報が得られます。

インストール後、__init__.py ファイルをダブルクリックして、コード補完を試してみてください。下の方にスピナーが表示されたら、完了するまで待ちます。その後、入力します。

from anki import hooks
hooks.

すると、補完がポップアップ表示されるはずです。

PyCharm 内からアドオンを実行することはできませんのでご注意ください。エラーが発生します。

アドオンは Anki 内から実行する必要があります。これは 基本的なアドオン のセクションで説明します。

MyPy

MyPy の利用

PyCharmのセットアップ の際にインストールした型ヒントは、MyPy というツールを使って、コードが正しいかどうかを確認することもできます。MyPy は、Anki の関数を誤って呼び出した場合、例えば関数名を間違って入力した場合や、整数を想定していたのに文字列を渡してしまった場合などに、その誤りを発見してくれます。

PyCharm で左下の Terminal をクリックし、'mypy myaddon' と入力してください。いくつかの処理の後、成功が表示されるか、またはあなたが犯したミスを教えてくれます。例えば、フックを間違って指定した場合を見てみましょう。

from aqt import gui_hooks

def myfunc() -> None:
  print("myfunc")

gui_hooks.reviewer_did_show_answer.append(myfunc)

すると mypy は次のように報告します:

myaddon/__init__.py:5: error: Argument 1 to "append" of "list" has incompatible type "Callable[[], Any]"; expected "Callable[[Card], None]"
Found 1 error in 1 file (checked 1 source file)

これは、フックが最初の引数としてカードを受け取る関数を期待していることを伝えています。

from anki.cards import Card

def myfunc(card: Card) -> None:
  print("myfunc")

既存のアドオンの確認

Mypy には "check_untyped_defs" オプションがあり、自分のコードに型ヒントがない場合でもある程度の型チェックを行うことができますが、これを最大限に活用するには、自分のコードに型ヒントを追加する必要があります。これは初期に時間がかかるかもしれませんが、長期的に見ると、自分のコードをナビゲートするのが容易になり、自分では定期的に実行しないようなコードの部分のエラーをキャッチすることができるようになるため有益です。また、新しい Anki バージョンに更新したときに発生した問題を簡単にチェックすることもできます。

既存の大規模なアドオンがある場合は、コードに自動的に型を追加する monkeytype のようなツールを検討するのもよいでしょう。

Monkeytype monkeytypeを test というアドオンで使うには、次のような方法があります。
% /usr/local/bin/python3.8 -m venv pyenv
% cd pyenv && . bin/activate
(pyenv) % pip install aqt monkeytype
(pyenv) % monkeytype run bin/anki

その後、アドオン内をクリックしてランタイムタイプの情報を収集し、終了したら Anki を閉じます。

この後、トップレベルのアクション(関数外のメニューを変更するコードなど)は monkeytype がトリップしてしまうので、コメントアウトする必要があります。最後に、修正したファイルを次のように生成します:

(pyenv) % PYTHONPATH=~/Library/Application\ Support/Anki2/addons21 monkeytype apply test

以下は、タイプヒントを使用するアドオンの例です:

https://github.com/ankitects/anki-addons/blob/master/demos/

アドオンフォルダ

トップレベルのアドオンフォルダには、Tools からアクセスできます。 Add-ons メニュー項目を選択します。[ファイルを見る]ボタンをクリックすると、フォルダがポップアップ表示されます。アドオンがインストールされていない場合、トップレベルのアドオンフォルダが表示されます。アドオンを選択していた場合は、アドオンのモジュールフォルダが表示されますので、1つ上の階層に移動する必要があります。

アドオンフォルダは、Anki 2.1 に対応する addons21 という名前になっています。addons フォルダがある場合は、以前に Anki 2.0.x を使用したことがあるためです。

各アドオンは、アドオンフォルダ内の1つのフォルダを使用します。Anki は、そのフォルダの中にある __init__.py というファイルを探します。

addons21/myaddon/__init__.py

もし __init__.py が存在しない場合、Anki はそのフォルダを無視します。

フォルダ名を決める際には、Pythonのモジュールシステムとの問題を避けるため、a-z と 0-9 の文字にこだわることをお勧めします。

自分で作成したフォルダには好きなフォルダ名を使用できますが、AnkiWeb からアドオンをダウンロードした場合、Anki はアイテムの ID をフォルダ名として使用し、次のようになります:

addons21/48927303923/__init__.py

また、Anki はフォルダ内に meta.json ファイルを配置し、オリジナルのアドオン名、ダウンロードされた時期が有効かどうかを記録しています。

ユーザーデータは ユーザーがアドオンをアップグレードすると削除されるので、アドオンフォルダには保存しない方がよいでしょう。

エディタの設定セクションの手順に従った場合、myaddon フォルダを Anki のアドオンフォルダにコピーしてテストするか、Mac や Linux ではフォルダの元の場所からアドオンフォルダへシンボリックリンクを作成してください。

A Basic Add-on

アドオンフォルダ内の my_first_addon/__init__.py に以下を追加してください。

# aqt からメインウィンドウオブジェクト (mw) をインポートする
from aqt import mw
# utils.py から "show info" ツールをインポートする
from aqt.utils import showInfo, qconnect
# Qt GUI ライブラリをすべてインポートする
from aqt.qt import *

# 下にメニュー項目を追加していきます。まず、メニュー項目がアクティブになったときに呼び出される関数を作成したいと思います。

def testFunction() -> None:
    # メインウィンドウに格納されている現在のコレクション内のカード枚数を取得します。
    cardCount = mw.col.cardCount()
    # メッセージボックスを表示する
    showInfo("Card count: %d" % cardCount)

# 新しいメニュー項目 "test "を作成する
action = QAction("test", mw)
# クリックされたときに testFunction を呼び出すように設定する
qconnect(action.triggered, testFunction)
# そしてツールメニューに追加する
mw.form.menuTools.addAction(action)

Anki を再起動すると、ツールメニューに「テスト」項目が表示されるはずです。 これを実行すると、カード枚数を示すダイアログが表示されます。

プラグインの入力に間違いがあった場合、起動時にエラーメッセージが表示され、問題の場所が示されます。

'anki' モジュール

コレクションや関連メディアへのアクセスは、Anki のソースレポジトリの pylib/anki にある anki という Python モジュールを通して行われます。

コレクション

コレクションファイルに対するすべての操作は Collection オブジェクトを介してアクセスされます。現在開いているコレクションは、グローバルな mw.col を介してアクセスできます。ここで mwmain window の略です。Anki の外部で anki モジュールを使用する場合は、独自のコレクションオブジェクトを作成する必要があります。

以下に、いくつかの基本的な例を示します。これらは testFunction() のような場所に記述する必要があることに注意してください。アドオンは Anki 起動時に、コレクションやプロファイルが読み込まれる前に初期化されるため、アドオンで直接実行することはできません。

また、コレクションに直接アクセスすると、操作が迅速に完了しない場合、UI が一時的にフリーズする可能性があることに注意してください。

期限のカードを取得する:

card = mw.col.sched.getCard()
if not card:
    # current deck is finished

カードに答える:

mw.col.sched.answerCard(card, ease)

ノートを編集する(各フィールドの末尾に new を付ける):

note = card.note()
for (name, value) in note.items():
    note[name] = value + " new"
mw.col.update_note(note)

タグ x を持つノートのカードの ID を取得する:

ids = mw.col.find_cards("tag:x")

それぞれの ID に対応する質問と回答を取得する:

for id in ids:
    card = mw.col.get_card(id)
    question = card.question()
    answer = card.answer()

レビューの期限を明日にする

ids = mw.col.find_cards("is:due")
mw.col.sched.set_due_date(ids, "1")

テキストファイルをコレクションにインポートする

このAPIは混乱していて、近々更新される予定です。

from anki.importing import TextImporter
file = u"/path/to/text.txt"
# デッキを選択する
deck_id = mw.col.decks.id("ImportDeck")
mw.col.decks.select(deck_id)
# ankiは選択されたデッキで最後に使用されたノートタイプをデフォルトとします
notetype = mw.col.models.by_name("Basic")
deck = mw.col.decks.get(deck_id)
deck['mid'] = notetype['id']
mw.col.decks.save(deck)
# ノートタイプで最後に使用したデッキにカードを入れる
mw.col.set_aux_notetype_config(
    notetype["id"], "lastDeck", deck_id
)
mw.col.models.save(m)
# コレクションに取り込む
ti = TextImporter(mw.col, file)
ti.initMapping()
ti.run()

ほぼすべてのGUI操作には Anki と関連する関数があり、Anki が利用可能な操作はすべてアドオンでも呼び出すことができます。

オブジェクトの読み取り/書き込み

Anki のほとんどのオブジェクトは、pylib のメソッドで読み書きが可能です。

card = col.get_card(card_id)
card.ivl += 1
col.update_card(card)
note = col.get_note(note_id)
note["Front"] += " hello"
col.update_note(note)
deck = col.decks.get(deck_id)
deck["name"] += " hello"
col.decks.save(deck)

deck = col.decks.by_name("Default hello")
...
config = col.decks.get_config(config_id)
config["new"]["perDay"] = 20
col.decks.save(config)
notetype = col.models.get(notetype_id)
notetype["css"] += "\nbody { background: grey; }\n"
col.models.save(note)

notetype = col.models.by_name("Basic")
...

データベースに直接アクセスするよりも、これらのメソッドを使用した方が、同期が必要な項目をマークしたり、無効なデータがデータベースに書き込まれるのを防いだりすることができるからです。

特定のカードやノートを探すには、 col.find_cards() と col.find_notes() が便利です。

データベース

:warning: データベースに直接書き込むと、簡単に問題を起こすことができます。可能な限り、代わりに上記のようなメソッドを使用してください。

Anki の DB オブジェクトは、以下の機能をサポートしています。

scalar() は単一の項目を返します:

showInfo("card count: %d" % mw.col.db.scalar("select count() from cards"))

list() は、各行の最初の列のリストを返します:

ids = mw.col.db.list("select id from cards limit 3")

all() は、行のリストを返します:

ids_and_ivl = mw.col.db.all("select id, ivl from cards")

execute() は、中間リストを作成せずに結果セットを反復処理するために使用することもできます:

for id, ivl in mw.col.db.execute("select id, ivl from cards limit 3"):
    showInfo("card id %d has ivl %d" % (id, ivl))

execute() を使用すると、挿入や更新の操作を実行することができます。名前付き引数を使用するには ? を使います:

mw.col.db.execute("update cards set ivl = ? where id = ?", newIvl, cardId)

なお、これらの変更は、前のセクションで説明した機能を使用した場合のように、同期されることはありません。

executemany() を使用すると、更新や挿入の操作を一括して行うことができます。大きな更新を行う場合は、データポイントごとに execute() をコールするよりもずっと高速になります:

data = [[newIvl1, cardId1], [newIvl2, cardId2]]
mw.col.db.executemany(same_sql_as_above, data)

上記のように、これらの変更は同期されません。

アドオンによって既存のテーブルのスキーマが変更されると、Anki の将来のバージョンで問題が発生する可能性があるため、絶対に変更しないでください。

アドオン固有のデータを保存する必要がある場合は、Anki の Configuration サポートの利用を検討してください。

デバイス間でデータを同期する必要がある場合、小さなオプションは mw.col.conf 内に保存することができます。現在、同期ごとに送信されるため、大量のデータをそこに保存しないようにしてください。

コマンドラインの使用

anki モジュールは、Anki の GUI とは別に使用することができます。.anki2 ファイルを直接読み書きする代わりに、このモジュールを使用することを強くお勧めします。

pipでインストールします。

$ pip install anki

.py ファイルの中で次のように作成します:

from anki.collection import Collection
col = Collection("/path/to/collection.anki2")
print(col.sched.deck_due_tree())
col.close()

詳しくは Ankiモジュール をご覧ください。

フックとフィルター

フックは、アドオンコードを Anki に接続するための方法です。変更したい関数にまだフックがない場合は、以下の新しいフックの追加に関するセクションを参照してください。

フックには2つの種類があります。

  • 通常のフックは、何も返さない関数です。これらは副作用のために実行され、時にはリストに余分な項目を挿入するなど、渡されたオブジェクトを変更することがあります。

  • フィルタは、最初の引数を変更した後にそれを返す関数です。例えば、カードの表示中にフィールドのテキストを受け取り、それを変更したものを返すようなフィルタです。

Python のデータ型には、直接変更できるものと、変更したコピーを作成することでしか変更できないもの(文字列など)があるので、この区別は必要です。

新しいスタイルのフック

Anki 2.1.20 では、新しいスタイルのフックが追加されました。

レビュー画面でカードの表側が表示されるたびにメッセージを表示したい場合を想像してください。reviewer.py のソースコードを見て、showQuestion() 関数の中に次のような行があるのを確認したとします。

gui_hooks.reviewer_did_show_question(card)

このフックが実行されたときに呼び出される関数を登録するには、アドオンで次のようにします:

from aqt import gui_hooks

def myfunc(card):
  print("question shown, card question is:", card.q())

gui_hooks.reviewer_did_show_question.append(myfunc)

複数のアドオンが同じフックやフィルターに登録することができ、それらは順番に呼び出されます。

フックを削除するには、次のようなコードを使用します。:

gui_hooks.reviewer_did_show_question.remove(myfunc)

:warning: フックにアタッチする関数は、実行中にフックを変更してはいけません:

def myfunc(card):
  # こんなことしちゃダメ!
  gui_hooks.reviewer_did_show_question.remove(myfunc)

gui_hooks.reviewer_did_show_question.append(myfunc)

すべてのフックを一目で見る簡単な方法は、pylib/tools/genhooks.py と qt/tools/genhooks_gui.py を見てみることです。

以前のセクションで説明したように、型補完を設定している場合は、IDE でフックを確認することもできます:

上のビデオでは、command/ctrl キーを押しながらホバーすると、引数やドキュメントが存在する場合はそれを含むツールチップが表示されます。コールバックの引数名と型は、下の方に表示されています。 の行をご覧ください。

新しいフックの使用例については、以下を参照してください https://github.com/ankitects/anki-addons/blob/master/demos/

新スタイルのフックのほとんどはレガシーフック (後述) も呼び出すので、古いアドオンも今のところ動作し続けますが、アドオン作者は新しいスタイルに更新することをお勧めします。

注目のフック

フックの完全なリストとそのドキュメントは、以下を参照してください。

Webview

Anki の多くの画面は、1 つ以上の webview で構築されており、その使用を妨害するために使用できるフックがいくつか存在します。

Anki 2.1.22 の場合- gui_hooks.webview_will_set_content() は、様々なスクリーンがウェブビューに送信する HTML を変更することができます。これは特定のスクリーンに独自の HTML/CSS/Javascript を追加するために使うことができます。これは外部ページには使えません。次の Anki 2.1.36 のセクションを参照してください。

  • gui_hooks.webview_did_receive_js_message() は、Javascript から送信されたメッセージを傍受することができます。Anki は Javascript に pycmd(string) 関数を用意しており、Python にメッセージを返し、reviewer.py などの様々な画面がそのメッセージに応答します。このフックを使うことで、自分自身のメッセージにも応答することができます:

Anki 2.1.36 の場合:

  • webview_did_inject_style_into_page()` は load_ts_page() でロードされるグラフ画面やお祝いページなどの外部ページにスタイルやコンテンツを注入する機会を提供します。

レガシーフック対応

旧バージョンのAnkiでは、runHook()、addHook()、runFilter()関数を使用した、異なるフックシステムを使用していました。

例えば、スケジューラ(anki/sched.py) がリーチを発見すると、呼び出されます。

runHook("leech", card)

もし、リーチが発見されたときに、カードを「難しい」デッキに移動させるなど、特別な操作を行いたい場合は、次のようなコードで実現することができます。:

from anki.hooks import addHook
from aqt import mw

def onLeech(card):
    # スケジューラが処理してくれるので、.flush() を使わなくても変更可能です 
    card.did = mw.col.decks.id("Difficult")
    # もしカードが cram デッキに入っていたなら、元の期限と元のデッキに戻さなければならない
    card.odid = 0
    if card.odue:
        card.due = card.odue
        card.odue = 0

addHook("leech", onLeech)

フィルタの例として、aqt/editor.pyがあります。エディタは、フィールドがフォーカスを失うたびに "editFocusLost" フィルタを呼び出すので、アドオンはノートに変更を適用することができます。:

if runFilter(
    "editFocusLost", False, self.note, self.currentField):
    # 何かがノートを更新しました;スケジュール再読み込み
    def onUpdate():
        self.loadNote()
        self.checkValid()
    self.mw.progress.timer(100, onUpdate, False)

この例の各フィルタは、修正フラグ、ノート、カレントフィールドの3つの引数を受け取ります。もしフィルターが何も変更しなければ、変更フラグを受け取ったときと同じものを返し、もし変更を加えれば、True を返します。この方法では、いずれかのアドオンが変更を加えた場合、UI はノートを再読み込みして更新を表示します。

日本語サポートアドオンでは、このフックを使って、あるフィールドから別のフィールドを自動生成しています。少し単純化したものを以下に示します:

def onFocusLost(flag, n, fidx):
    from aqt import mw
    # 日本語の model?
    if "japanese" not in n.model()['name'].lower():
        return flag
    #  srcとdstのフィールドがあるか
    for c, name in enumerate(mw.col.models.fieldNames(n.model())):
        for f in srcFields:
            if name == f:
                src = f
                srcIdx = c
        for f in dstFields:
            if name == f:
                dst = f
    if not src or not dst:
        return flag
    # dstフィールドがすでに埋まっているか
    if n[dst]:
        return flag
    # イベントが src フィールドから来るか
    if fidx != srcIdx:
        return flag
    # ソーステキストの補足
    srcTxt = mw.col.media.strip(n[src])
    if not srcTxt:
        return flag
    # フィールドを更新する
    try:
        n[dst] = mecab.reading(srcTxt)
    except Exception, e:
        mecab = None
        raise
    return True

addHook('editFocusLost', onFocusLost)

フィルタの第一引数は、返されるべき引数である。 フォーカスロスト・フィルタでは、これはフラグであるが、他のケースでは他のオブジェクトである場合もある。例えば、杏樹/collection.pyでは、"mungeQA "フィルターを呼び出し、カードの表と裏のHTMLを生成して格納します。

Anki 2.1 では、エディタにボタンを追加するためのフックが追加されました。これは次のように使用します。:

from aqt.utils import showInfo
from anki.hooks import addHook

# 選択中のテキストを消去する
def onStrike(editor):
    editor.web.eval("wrap('<del>', '</del>');")

def addMyButton(buttons, editor):
    editor._links['strike'] = onStrike
    return buttons + [editor._addButton(
        "iconname", # "/full/path/to/icon.png",
        "strike", # link の名前
        "tooltip")]

addHook("setupEditorButtons", addMyButton)

フックの追加

もし、まだフックがない関数を修正したい場合は、必要なフックを追加するプルリクエストを提出してください。

フックの定義は pylib/tools/genhooks.pyqt/tools/genhooks_gui.py に置かれています。Anki のビルド時に、ビルドスクリプトが自動的にフックファイルを更新し、そこに記載されている定義が適用されます。

詳細については、ソースツリーの docs/ フォルダを参照してください。

バックグラウンドの操作

アドオンが長時間実行される操作を直接行った場合、操作が完了するまでユーザーインターフェースがフリーズし、進行状況ウィンドウが表示されず、アプリが停止しているように見えます。これはユーザーにとって迷惑なことなので、このようなことが起こらないように注意する必要があります。

この現象が起こる理由は、ユーザーインターフェイスが「メインスレッド」上で動作しているからです。アドオンが長時間実行される操作を直接行うと、それもメインスレッド上で実行され、操作が完了するまでUIコードが再び実行されないようにします。解決策は、アドオンのコードをバックグラウンドスレッドで実行し、UIが引き続き機能するようにすることです。

複雑なのは、UIとやりとりするコードもメインスレッドで実行する必要があることです。アドオンがバックグラウンドでのみ実行され、UIにアクセスしようとすると、Ankiがクラッシュする原因となります。つまり、UI操作はメインスレッドで実行し、コレクションやネットワークアクセスなどの長時間実行される操作はバックグラウンドで実行するという選択性が必要です。Anki には、これを容易にするツールがいくつか用意されています。

読み取り専用操作と読み取り専用でない操作

ノートのグループを集めたり、ネットワークアクセスのような長時間実行される操作には、 QueryOp が推奨されます。

次の例では、my_ui_action() はすぐに戻り、操作は完了するまでバックグラウンドで実行され続けます。正常に終了すると、on_success が呼び出されます。

from anki.collection import Collection
from aqt.operations import QueryOp
from aqt.utils import showInfo
from aqt import mw

def my_background_op(col: Collection, note_ids: list[int]) -> int:
    # 長い時間がかかる操作の例
    for id in note_ids:
        note = col.get_note(note_id)
        # ...

    return 123

def on_success(count: int) -> None:
    showInfo(f"my_background_op() returned {count}")

def my_ui_action(note_ids: list[int]):
    op = QueryOp(
        # アクティブウィンドウ(ここではメインウィンドウ)
        parent=mw,
        # 操作には便宜上コレクションが渡されますが、無視してもかまいません
        op=lambda col: my_background_operation(col, note_ids),
        # この関数は、op が正常に終了したときに呼び出され、op の戻り値が渡されます
        success=on_success,
    )

    # with_progress()が呼ばれない場合、プログレスウィンドウは表示されません
    # QueryOp.with_progress() は、Anki 2.1.50 までは壊れていました
    op.with_progress().run_in_background()

バックグラウンド操作の内部でQt/UI ルーチンを直接呼び出さないように注意してください!

  • 操作完了後に UI を変更する必要がある場合(例:ツールチップを表示する)、成功関数から行う必要があります。
  • 操作に UI のデータが必要な場合(例:コンボボックスの値)、そのデータは操作の実行前に収集しておく必要があります。
  • バックグラウンドでの操作中にUIを更新する必要がある場合(例:プログレスウィンドウのテキストを更新する)、操作はメインスレッドでその更新を実行する必要があります。例えば、ループ内での操作を見てみましょう:
if time.time() - last_progress >= 0.1:
    aqt.mw.taskman.run_on_main(
        lambda: aqt.mw.progress.update(
            label=f"Remaining: {remaining}",
            value=total - remaining,
            max=total,
        )
    )
    last_progress = time.time()

コレクションの操作

コレクションを修正する取り消し可能な操作のために、別の CollectionOp が提供されています。これは QueryOp と同様に機能しますが、変更が行われると UI も更新されます (例えば、ノートが変更されたら Browse 画面をリフレッシュします)。

多くの元に戻せない操作は、すでに aqt/operations/*.py で CollectionOp を定義しています。多くの場合、自分で作成するよりも、それらのいずれかを直接使用することができます:

from aqt.operations.note import remove_notes

def my_ui_action(note_ids: list[int]) -> None:
    remove_notes(parent=mw, note_ids=note_ids).run_in_background()

デフォルトでは、このルーチンは成功時にツールチップを表示します。.success()または .failure() を呼び出すことで、別のルーチンを提供することができます。

複数の操作を1つの取り消し(undo)のステップにまとめるなど、取り消しの処理に関するより詳しい情報は、このフォーラムのページ を参照してください。

Qt と PyQt

概要で述べたように、Ank iは UI の多くに PyQt を使用しており、Qt のドキュメントとPyQt documentation は、さまざまな GUI ウィジェットを表示する方法を学ぶのに非常に有益です。

Qt のバージョン

Anki 2.1.50 からは、PyQt5 と PyQt6 用に別々のビルドが提供されます。一般的には、Qt6 で動作するコードを書き、Qtのクラスを PyQt6 から直接ではなく、aqt.qt からインポートするようにすれば、あなたのコードは Qt5 でも動くはずです。

デザイナー向けファイル

Anki の UI の一部は、qt/aqt/forms にある .ui ファイルで定義されています。Anki のビルドプロセスは、それらを .py ファイルに変換します。同様の方法でアドオンの UI を構築したい場合は、Python をインストールし、Qt Designer (macOS では Designer.app) と呼ばれるプログラムをインストールする必要があります。Linuxでは、ディストロのパッケージで利用できるかもしれません。WindowsとMacでは、Qt install の一部としてインストールする必要があります。一度インストールしたら、pyqt6 pip パッケージで提供されるプログラムを使って、.ui ファイルをコンパイルする必要があります。

PyQt6 用に生成された Python ファイルはPyQt5 では動作しませんし、その逆も同様です。したがって、両方のバージョンをサポートしたい場合は、.ui ファイルを2回ビルドする必要があります。

ガベージコレクション

特に注意しなければならないのは、Python ではオブジェクトはガベージコレクションされるので、次のようなことをすると、ガベージコレクションされます:

def myfunc():
    widget = QWidget()
    widget.show()

その場合、関数が終了すると同時にウィジェットは消えてしまいます。これを防ぐには、トップレベルのウィジェットを、以下のように既存のオブジェクトに割り当ててください:

def myfunc():
    mw.myWidget = widget = QWidget()
    widget.show()

Qt オブジェクトを作成し、既存のオブジェクトを親として与えた場合、親がオブジェクトへの参照を保持するため、これはしばしば必要ではありません。

Python のモジュール

Anki 2.1.50 以降、パッケージビルドにはほとんどの組み込み Python モジュールが含まれています。それ以前のバージョンでは、Anki の実行に必要な標準モジュールのみが同梱されています。

同梱されていない標準の Python モジュールや PyPI のパッケージをアドオンで使用する場合、アドオンでモジュールをバンドルする必要があります。

純粋な Python モジュールの場合、これは通常、サブフォルダに配置し、sys.path を調整するのと同じくらい簡単です。numpy などの C 拡張を必要とするモジュールの場合は、各プラットフォームの異なるモジュールバージョンをバンドルし、Anki がパッケージングされている Python のバージョンと互換性のあるバージョンをバンドルする必要があるため、かなり複雑になります。

アドオンの設定

設定用の JSON

JSON 形式の config.json ファイルを含めると、Anki はユーザーがアドオンマネージャーから編集できるようになります。

簡単な config.json の例:

{"myvar": 5}

config.md の例:

これは、このアドオンの設定に関するドキュメントで、*markdown* 形式で書かれています。

アドオンのコードは以下の通りです:

from aqt import mw
config = mw.addonManager.getConfig(__name__)
print("var is", config['myvar'])

アドオンをアップデートする際、config.json を変更することができます。新しく追加されたキーは、既存の設定にマージされます。

config.json で既存のキーの値を変更した場合、設定をカスタマイズしているユーザーは、「restore defaults」ボタンを使用しない限り、古い値を表示し続けることになります。

プログラム的に設定を変更する必要がある場合、変更内容を保存するには次のようにします:

mw.addonManager.writeConfig(__name__, config)

config.json ファイルが存在しない場合、たとえ writeConfig() を呼び出したとしても、getConfig() は None を返します。

独自の GUI でオプションを管理するアドオンでは、config ボタンをクリックするとその GUI が表示されます:

mw.addonManager.setConfigAction(__name__, myOptionsFunc)

アンダースコアで始まるキー名は避けてください。これらは Anki が将来使用するために予約されています。

ユーザーファイル

アドオンが単純なキーと値以外の設定データを必要とする場合、アドオンのフォルダのルートにある user_files という特別なフォルダを使用することができます。このフォルダに配置されたファイルは、アドオンがアップグレードされても保存されます。アドオンフォルダ内の他のファイルはすべて アップグレード時に削除されます。

user_files フォルダが確実にユーザー用に作成されるようにするには、アドオンを圧縮する前に README.txt などのファイルをその中に入れておくとよいでしょう。

Anki がアドオンをアップグレードするとき、user_files フォルダに既に存在する .zip 内のファイルは無視されます。

reviewer の Javascript

カードレビューに特化しない一般的な解決策については、webview セクション を参照してください。

Anki は、レビュー画面、プレビューダイアログ、カードレイアウト画面に表示される前に、質問と回答の HTML を修正するフックを提供します。これは、カードに Javascript を追加するのに便利です。

例は、以下の通りです。

from aqt import gui_hooks
def prepare(html, card, context):
    return html + """
<script>
document.body.style.background = "blue";
</script>"""
gui_hooks.card_will_show.append(prepare)

このフックは 3 つの引数を取ります: 質問または回答の HTML 、現在のカードオブジェクト(例えばアドオンを特定のノートタイプに制限できる)、フックが実行されているコンテキストを表す文字列です。

修正された HTML を返すことを確認してください。

コンテキストは以下のいずれかです。contextは、"reviewQuestion", "reviewAnswer", "clayoutQuestion", "clayoutAnswer", "previewQuestion", "previewAnswer "のうちの1つです。

カードレイアウト画面での回答プレビュー、および「両面表示」に設定されたプレビューアでは、「回答」コンテキストのみが使用されます。つまり、カードの裏側に追加するJavascript は、表側だけに追加されるJavascript に依存しないようにする必要があります。

Anki は新しいテキストを表示する前に前のテキストをフェードアウトさせるため、正しいタイミングでスクロールなどのアクションを実行するには、Javascript のフックが必要です。以下のように使用します:

from aqt import gui_hooks
def prepare(html, card, context):
    return html + """
<script>
onUpdateHook.push(function () {
    window.scrollTo(0, 2000);
})
</script>"""
gui_hooks.card_will_show.append(prepare)
  • onUpdateHook は、新しいカードが DOM に配置された後、表示される前に起動されます。

  • onShownHook は、カードがフェードインした後に発生します。

フックは、質問と回答が表示されるたびにリセットされます。

デバッグ

コードが例外をスローした場合、Anki の標準的な例外ハンドラでキャッチされます(標準エラー出力に書き込まれたものはすべて捕捉されます)。デバッグのために情報を表示する必要がある場合は、aqt.utils.showInfo を使用するか、sys.stderr.write("text\n") で stderr に情報を書き込むことができます。

Webviews

Anki を起動する前に環境変数の QTWEBENGINE_REMOTE_DEBUGGING を 8080 に設定すると、Chrome で http://localhost:8080 にサーフィンして、見えるウェブページをデバッグすることができるようになります。

デバッグ用コンソール

Ankiには REPL も搭載されています。プログラム内からショートカットキーを押すと、ウィンドウが表示されます。上の領域に式や文を入力し、ctrl+return/command+return を押すと、その式や文を評価することができます。 セッションの例を以下に示します:

>>> mw
<no output>

>>> print(mw)
<aqt.main.AnkiQt object at 0x10c0ddc20>

>>> invalidName
Traceback (most recent call last):
  File "/Users/dae/Lib/anki/qt/aqt/main.py", line 933, in onDebugRet
    exec text
  File "<string>", line 1, in <module>
NameError: name 'invalidName' is not defined

>>> a = [a for a in dir(mw.form) if a.startswith("action")]
... print(a)
... print()
... pp(a)
['actionAbout', 'actionCheckMediaDatabase', ...]

['actionAbout',
 'actionCheckMediaDatabase',
 'actionDocumentation',
 'actionDonate',
 ...]

>>> pp(mw.reviewer.card)
<anki.cards.Card object at 0x112181150>

>>> pp(card()) # shortcut for mw.reviewer.card.__dict__
{'_note': <anki.notes.Note object at 0x11221da90>,
 '_qa': [...]
 'col': <anki.collection._Collection object at 0x1122415d0>,
 'data': u'',
 'did': 1,
 'due': -1,
 'factor': 2350,
 'flags': 0,
 'id': 1307820012852L,
 [...]
}

>>> pp(bcard()) # shortcut for selected card in browser
<as above>

評価結果を見るためには、明示的に式を表示する必要があることに注意してください。Anki は pp() (pretty print) をスコープにエクスポートして、オブジェクトの詳細を簡単にダンプできるようにしています。ショートカットの ctrl+shift+return は、上部の領域にある現在のテキストを pp() でラップして、その結果を実行するものです。

PDB

Linux またはソースから Anki を実行している場合、pdb を使用してスクリプトをデバッグすることも可能です。次の行をコードのどこかに記述すると、Anki がその行に到達したときに、ターミナルのデバッガが起動されます。:

    from aqt.qt import debug; debug()

また、シェルで DEBUG=1 を指定しておけば、キャッチできない例外が発生したときにデバッガが起動します。

モンキーパッチングとメソッドラッピング

フックを持たない関数を変更したい場合、その関数をカスタムバージョンで上書きすることが可能です。これは「モンキーパッチ」と呼ばれることもあります。

モンキーパッチは、テスト段階や、Anki に新しいフックが統合されるのを待っている間などに便利です。しかし、モンキーパッチは非常に壊れやすく、将来 Anki が更新されたときに壊れる可能性があるため、長期的に依存しないようにしてください。

上記の唯一の例外は、新しいフックを追加することが現実的でないような大規模な変更を Anki に加えている場合です。その場合、残念ながら、Anki の更新に合わせて定期的にアドオンを修正する必要があるかもしれません。

aqt/editor.py には setupButtons() という関数があり、エディタに表示される太字や斜体のようなボタンを作成することができます。アドオンで別のボタンを追加したい場合を考えてみましょう。

Anki 2.1 では setupButtons() を使用しなくなりました。以下のコードは、モンキーパッチの仕組みを理解するのにまだ役立ちますが、エディタにボタンを追加するには、前のセクションで説明した setupEditorButtons フックを参照してください。

最も簡単な方法は、Anki ソースコードから関数をコピー&ペーストして、テキストを一番下に追加し、元のコードを上書きすることです:

from aqt.editor import Editor

def mySetupButtons(self):
    <copy & pasted code from original>
    <custom add-on code>

Editor.setupButtons = mySetupButtons

しかし、この方法はもろいもので、Anki の将来のバージョンでオリジナルのコードが更新された場合、あなたのアドオンも更新する必要があります。より良い方法は、オリジナルを保存し、カスタムバージョンでそれを呼び出すことです:

from aqt.editor import Editor

def mySetupButtons(self):
    origSetupButtons(self)
    <custom add-on code>

origSetupButtons = Editor.setupButtons
Editor.setupButtons = mySetupButtons

これはよくある操作なので、Anki は wrap() という関数を用意して、これを少し便利にしています。実際の例は、次の通りです:

from anki.hooks import wrap
from aqt.editor import Editor
from aqt.utils import showInfo

def buttonPressed(self):
    showInfo("pressed " + `self`)

def mySetupButtons(self):
    # - size=False は、Anki が小さなボタンを使用しないように指示します
    # - バインドメソッドではなく関数を渡しているので、lambda はコールバックにエディタインスタンスを渡すために必要です
    self._addButton("mybutton", lambda s=self: buttonPressed(self),
                    text="PressMe", size=False)

Editor.setupButtons = wrap(Editor.setupButtons, mySetupButtons)

デフォルトでは、wrap() は元のコードの後にカスタムコードを実行します。これを逆転させるために、第3引数 "before" を渡すことができます。元のバージョンの前と後の両方でコードを実行する必要がある場合、次のようにします:

from anki.hooks import wrap
from aqt.editor import Editor

def mySetupButtons(self, _old):
    <before code>
    ret = _old(self)
    <after code>
    return ret

Editor.setupButtons = wrap(Editor.setupButtons, mySetupButtons, "around")

アドオンの共有

AnkiWeb による共有

アドオンを配布するためにパッケージ化するには、zip 圧縮して .ankiaddon で終わる名前をつけます。

トップレベルフォルダは zip ファイルに含めないでください。例えば、次のようなモジュールがあるとします:

addons21/myaddon/__init__.py
addons21/myaddon/my.data

ZIPファイルの中身は以下の通りです:

__init__.py
my.data

以下のように zip にフォルダ名を含めると、AnkiWeb は zip ファイルを受け付けません。:

myaddon/__init__.py
myaddon/my.data

Unix ベースのマシンでは、次のコマンドで正しい形式のファイルを作成することができます。:

$ cd myaddon && zip -r ../myaddon.ankiaddon *

Python はアドオン実行時に自動的に pycache フォルダを作成します。AnkiWeb は pycache フォルダを含む zip ファイルを受け付けないため、zip ファイルを作成する前にこれらを削除しておいてください。

.ankiaddon ファイルを作成したら、https://ankiweb.net/shared/addons/ にある Upload ボタンを使ってアドオンを他の人と共有することができます。

AnkiWeb 以外での共有

.ankiaddon ファイルを AnkiWeb 以外で配布する場合、アドオンフォルダに manifest.json ファイルが含まれている必要があります。このファイルには、少なくとも2つのキーを含める必要があります。 package はアドオンが格納されるフォルダ名を指定し、nameはユーザーに表示される名前を指定します。オプションで、アドオンと競合する他のパッケージのリストである conflicts キーと、アドオンがいつ更新されたかを指定する mod キーを含めることができます。

Anki が AnkiWeb からアドオンをダウンロードする場合、マニフェストから conflicts キーのみが使用されます。

2.0 版アドオンの移植

Python 3

Anki 2.1 には Python 3 以降が必要です。Python 3 をマシンにインストールした後、2to3 ツールを使用して、以下のように既存のスクリプトをフォルダごとに Python 3 コードに自動的に変換することができます:

2to3-3.8 --output-dir=aqt3 -W -n aqt
mv aqt aqt-old
mv aqt3 aqt

単純なコードのほとんどは自動的に変換できますが、手動で修正する必要がある部分があるかもしれません。

Qt5 / PyQt5

PyQt5では、シグナルとスロットを接続するための構文が変更されました。最近の PyQt4 バージョンでは新しい構文もサポートしているので、Anki 2.0 と 2.1 の両方のアドオンに同じ構文を使用することができます。

詳細は、http://pyqt.sourceforge.net/Docs/PyQt4/new_style_signals_slots.html にあります。

あるアドオン作者は、コードを自動変換するために、以下のツールが便利であったと報告しています。 https://github.com/rferrazz/pyqt4topyqt5

Qt モジュールは PyQt4 ではなく PyQt5 になっています。条件付きでインポートすることもできますが、より簡単な方法は、次のように aqt.qt からインポートすることです:

from aqt.qt import *

これにより、Qt のバージョンを指定することなく、QDialog のようなすべての Qt オブジェクトをインポートすることができます。

単一の .py アドオンには専用のフォルダが必要

各アドオンは独自のフォルダに格納されるようになりました。もし、あなたのアドオンが以前は demo.py という名前だった場合、demo フォルダを作成して __init__.py ファイルを作成する必要があります。

2.0 との互換性を気にしないのであれば、 demo.pydemo/__init__.py にリネームすればよいでしょう。

もし、同じファイルで 2.0 に対応するつもりなら、元のファイルをフォルダにコピーし(demo.pydemo/demo.py )、demo/__init__.py に以下を追加して相対的にインポートすることが可能です:

from . import demo

AnkiWeb にアップロードする際は、フォルダを ZIP で圧縮する必要があります。詳しくは、アドオンの共有を参照してください。

アップグレード時にフォルダが削除される

アドオンをアップグレードすると、アドオンフォルダ内のファイルはすべて削除されます。唯一の例外は、特別な user_files folder です。アドオンが単純なキー/値設定以上のものを必要とする場合、関連するファイルを user_files フォルダに保存していることを確認してください。そうしなければ、アップグレード時に失われるでしょう。

1つのコードベースで 2.0 と 2.1 の両方に対応する

Python 3 のコードのほとんどは Python 2 でも実行できるため、Anki 2.0 と 2.1 の両方で実行できるようにアドオンを更新することができます。その価値があるかどうかは、必要な変更によります。

スケジューラに影響を与えるほとんどのアドオンは、2.1 で動作させるためにわずかな変更で済むはずです。レビューア、ブラウザ、エディタの動作を変更するアドオンは、より多くの作業を必要とする可能性があります。

最も難しいのは、サポートされていないQtWebKit から QtWebEngine への変更です。WebView を使用する場合、Anki 2.1 にコードを移植する作業が必要になり、1 つのコード ベースで両方の Anki バージョンをサポートすることが難しくなる可能性があります。

アドオンが修正なしで動作する場合や、わずかな変更で済む場合は、コードに if 文を追加して、2.0.x と 2.1.x の両方に同じファイルをアップロードするのが最も簡単でしょう。

アドオンに大幅な変更が必要な場合は、2.0.x の更新を停止するか、2 つの Anki バージョン用に別々のファイルを維持する方が簡単かもしれません。

Webview の変更

Qt 5 では WebKit が廃止され、Chromium ベースの WebEngine が採用されたため、Anki のウェブビューは WebEngine を使用するようになりました。注目すべきは以下の点です :

  • Anki を起動する前に環境変数 QTWEBENGINE_REMOTE_DEBUGGING を 8080 に設定し、Chrome で localhost:8080 にアクセスすると、外部の Chrome インスタンスを使用して WebView をデバッグすることができるようになりました。

  • WebEngine は、Python に戻る通信に別の方法を使用します。AnkiWebView() はウェブビューのラッパーで、Javascript で pycmd(str) 関数を提供し、ankiwebview の onBridgeCmd(str) メソッドを呼び出すことができます。reviewer.py や deckbrowser.py など、Anki の UI のさまざまな部分は、これを使用するために修正する必要がありました。

  • Javascript は非同期に評価されるので、JS 式の結果が必要な場合は、ankiwebview の evalWithCallback() を使用します。

  • この非同期動作の結果、editor.saveNow()はコールバックを必要とするようになりました。アドオンがブラウザ上でアクションを実行する場合、まず editor.saveNow() を呼び出し、その後コールバックで残りのコードを実行する必要があるでしょう。.onSearch()への呼び出しは、同様に .search()/.onSearchActivated() に変更する必要があります。例として、ブラウザの .deleteNotes() を参照してください。

  • setScrollPosition() のように WebKit でサポートされていたさまざまな操作は、javascript で実装する必要があります。

  • mw.web.triggerPageAction(QWebEnginePage.Copy) のようなページアクションも非同期なので、JavaScript や遅延を使うように書き直す必要があります。

  • WebEngine は WebKit のようにkeyPressEvent() を提供しないので、メニューやボタンに付属しないショートカットをキャッチするコードを変更する必要がありました。 setStateShortcuts() は、与えられた状態に対するショートカットを調整するために使用するフックを発生させます。

Reviewer の変更

Anki は、前のカードをフェードアウトしてから次のカードをフェードインするようになったため、showQuestion フックが発生したときに次のカードが DOM に表示されなくなります。適切なタイミングで Javascript を実行するために使用できる新しいフックがいくつかあります - 詳しくは こちら をご覧ください。

アドオンの設定

2.0 の小さなアドオンの多くは、ユーザーがソースコードを編集してカスタマイズすることに依存していました。これは、2.1 ではもはや良いアイデアではありません。なぜなら、ユーザーによってなされた変更は、アップデートをチェックしダウンロードするときに上書きされるからです。2.1 では、これを回避するために 設定 のシステムを提供しています。2.0 もサポートし続ける必要がある場合は、以下のようなコードを使用することができます:

if getattr(getattr(mw, "addonManager", None), "getConfig", None):
    config = mw.addonManager.getConfig(__name__)
else:
    config = dict(optionA=123, optionB=456)