diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 826f9c1..72bf0ce 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -12,8 +12,15 @@ jobs:
sudo apt-get update
sudo apt-get install -y pyqt5-dev-tools xvfb
- - name: Setup Python
+ - name: Setup Python 3.8
uses: actions/setup-python@v2
+ with:
+ python-version: 3.8
+
+ - name: Setup Python 3.9
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.9
- name: Install tox
run: pip install tox
diff --git a/.gitignore b/.gitignore
index 34ac317..fc4b3d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
AnkiConnect.zip
meta.json
.idea/
+.tox/
diff --git a/plugin/__init__.py b/plugin/__init__.py
index 6b50f4d..898f81b 100644
--- a/plugin/__init__.py
+++ b/plugin/__init__.py
@@ -13,6 +13,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import aqt
+
+anki_version = tuple(int(segment) for segment in aqt.appVersion.split("."))
+
+if anki_version < (2, 1, 45):
+ raise Exception("Minimum Anki version supported: 2.1.45")
+
import base64
import glob
import hashlib
@@ -20,34 +27,23 @@ import inspect
import json
import os
import os.path
-import random
+import platform
import re
-import string
import time
import unicodedata
-from PyQt5 import QtCore
-from PyQt5.QtCore import QTimer
-from PyQt5.QtWidgets import QMessageBox, QCheckBox
-
import anki
import anki.exporting
import anki.storage
-import aqt
from anki.cards import Card
from anki.consts import MODEL_CLOZE
-
from anki.exporting import AnkiPackageExporter
from anki.importing import AnkiPackageImporter
from anki.notes import Note
+from anki.errors import NotFoundError
+from aqt.qt import Qt, QTimer, QMessageBox, QCheckBox
from .edit import Edit
-
-try:
- from anki.rsbackend import NotFoundError
-except:
- NotFoundError = Exception
-
from . import web, util
@@ -56,8 +52,6 @@ from . import web, util
#
class AnkiConnect:
- _anki21_version = int(aqt.appVersion.split('.')[-1])
-
def __init__(self):
self.log = None
self.timer = None
@@ -84,11 +78,7 @@ class AnkiConnect:
)
def save_model(self, models, ankiModel):
- if self._anki21_version < 45:
- models.save(ankiModel, True)
- models.flush()
- else:
- models.update_dict(ankiModel)
+ models.update_dict(ankiModel)
def logEvent(self, name, data):
if self.log is not None:
@@ -391,7 +381,7 @@ class AnkiConnect:
msg.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
msg.setDefaultButton(QMessageBox.No)
msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg))
- msg.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msg.setWindowFlags(Qt.WindowStaysOnTopHint)
pressedButton = msg.exec_()
if pressedButton == QMessageBox.Yes:
@@ -547,9 +537,8 @@ class AnkiConnect:
# however, since 62c23c6816adf912776b9378c008a52bb50b2e8d (2.1.45)
# passing cardsToo to `rem` (long deprecated) won't raise an error!
# this is dangerous, so let's raise our own exception
- if self._anki21_version >= 28:
- raise Exception("Since Anki 2.1.28 it's not possible "
- "to delete decks without deleting cards as well")
+ raise Exception("Since Anki 2.1.28 it's not possible "
+ "to delete decks without deleting cards as well")
try:
self.startEditing()
decks = filter(lambda d: d in self.deckNames(), decks)
@@ -1425,9 +1414,7 @@ class AnkiConnect:
if savedMid:
deck['mid'] = savedMid
- addCards.editor.note = ankiNote
- addCards.editor.loadNote()
- addCards.editor.updateTags()
+ addCards.editor.set_note(ankiNote)
addCards.activateWindow()
@@ -1633,6 +1620,9 @@ class AnkiConnect:
# when run inside Anki, `__name__` would be either numeric,
# or, if installed via `link.sh`, `AnkiConnectDev`
if __name__ != "plugin":
+ if platform.system() == "Windows" and anki_version == (2, 1, 50):
+ util.patch_anki_2_1_50_having_null_stdout_on_windows()
+
Edit.register_with_anki()
ac = AnkiConnect()
diff --git a/plugin/edit.py b/plugin/edit.py
index 1e063af..a8c5ac9 100644
--- a/plugin/edit.py
+++ b/plugin/edit.py
@@ -1,5 +1,6 @@
import aqt
import aqt.editor
+import aqt.browser.previewer
from aqt import gui_hooks
from aqt.qt import QDialog, Qt, QKeySequence, QShortcut
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip
@@ -7,6 +8,8 @@ from anki.errors import NotFoundError
from anki.consts import QUEUE_TYPE_SUSPENDED
from anki.utils import ids2str
+from . import anki_version
+
# Edit dialog. Like Edit Current, but:
# * has a Preview button to preview the cards for the note
@@ -184,7 +187,7 @@ class Edit(aqt.editcurrent.EditCurrent):
# upon a request to open the dialog via `aqt.dialogs.open()`,
# the manager will call either the constructor or the `reopen` method
def __init__(self, note):
- QDialog.__init__(self, None, Qt.Window)
+ QDialog.__init__(self, None, Qt.WindowType.Window)
aqt.mw.garbage_collect_on_dialog_finish(self)
self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self)
@@ -225,8 +228,6 @@ class Edit(aqt.editcurrent.EditCurrent):
if changes.note_text and handler is not self.editor:
self.reload_notes_after_user_action_elsewhere()
- # adjusting buttons right after initializing doesn't have any effect;
- # this seems to do the trick
def editor_did_load_note(self, _editor):
self.enable_disable_next_and_previous_buttons()
@@ -304,29 +305,66 @@ class Edit(aqt.editcurrent.EditCurrent):
gui_hooks.editor_did_init.append(self.add_preview_button)
gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons)
- self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self)
+ # on Anki 2.1.50, browser mode makes the Preview button visible
+ extra_kwargs = {} if anki_version < (2, 1, 50) else {
+ "editor_mode": aqt.editor.EditorMode.BROWSER
+ }
+
+ self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self,
+ **extra_kwargs)
gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons)
gui_hooks.editor_did_init.remove(self.add_preview_button)
- # taken from `setupEditor` of browser.py
- # PreviewButton calls pycmd `preview`, which is hardcoded.
- # copying _links is needed so that opening Anki's browser does not
- # screw them up as they are apparently shared between instances?!
+ # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
+ # also, make a copy of _links so that opening Anki's browser does not
+ # screw them up as they are apparently shared between instances?!
+ # the last part seems to have been fixed in Anki 2.1.50
+ # * on Anki 2.1.50, the button is created by setting editor mode,
+ # see above; so we only need to add the link.
def add_preview_button(self, editor):
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
- editor._links = editor._links.copy()
- editor._links["preview"] = self.show_preview
- editor.web.eval("""
- $editorToolbar.then(({notetypeButtons}) =>
- notetypeButtons.appendButton(
- {component: editorToolbar.PreviewButton, id: 'preview'}
- )
- );
- """)
+ if anki_version < (2, 1, 50):
+ editor._links = editor._links.copy()
+ editor.web.eval("""
+ $editorToolbar.then(({notetypeButtons}) =>
+ notetypeButtons.appendButton(
+ {component: editorToolbar.PreviewButton, id: 'preview'}
+ )
+ );
+ """)
+ editor._links["preview"] = lambda _editor: self.show_preview() and None
+
+ # * on Anki < 2.1.50, button style is okay-ish from get-go,
+ # except when disabled; adding class `btn` fixes that;
+ # * on Anki 2.1.50, buttons have weird font size and are square';
+ # the style below makes them in line with left-hand side buttons
def add_right_hand_side_buttons(self, buttons, editor):
+ if anki_version < (2, 1, 50):
+ extra_button_class = "btn"
+ else:
+ extra_button_class = "anki-connect-button"
+ editor.web.eval("""
+ (function(){
+ const style = document.createElement("style");
+ style.innerHTML = `
+ .anki-connect-button {
+ white-space: nowrap;
+ width: auto;
+ padding: 0 2px;
+ font-size: var(--base-font-size);
+ }
+ .anki-connect-button:disabled {
+ pointer-events: none;
+ opacity: .4;
+ }
+ `;
+ document.head.appendChild(style);
+ })();
+ """)
+
def add(cmd, function, label, tip, keys):
button_html = editor.addButton(
icon=None,
@@ -338,30 +376,34 @@ class Edit(aqt.editcurrent.EditCurrent):
keys=keys,
)
- # adding class `btn` properly styles buttons when disabled
- button_html = button_html.replace('class="', 'class="btn ')
+ button_html = button_html.replace('class="',
+ f'class="{extra_button_class} ')
buttons.append(button_html)
add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
add("previous", self.show_previous, "<", "Previous", "Alt+Left")
add("next", self.show_next, ">", "Next", "Alt+Right")
+ def run_javascript_after_toolbar_ready(self, js):
+ js = f"setTimeout(function() {{ {js} }}, 1)"
+ if anki_version < (2, 1, 50):
+ js = f'$editorToolbar.then(({{ toolbar }}) => {js})'
+ else:
+ js = f'require("anki/ui").loaded.then(() => {js})'
+ self.editor.web.eval(js)
+
def enable_disable_next_and_previous_buttons(self):
def to_js(boolean):
return "true" if boolean else "false"
- disable_previous = to_js(not(history.has_note_to_left_of(self.note)))
- disable_next = to_js(not(history.has_note_to_right_of(self.note)))
+ disable_previous = not(history.has_note_to_left_of(self.note))
+ disable_next = not(history.has_note_to_right_of(self.note))
- self.editor.web.eval(f"""
- $editorToolbar.then(({{ toolbar }}) => {{
- setTimeout(function() {{
- document.getElementById("{DOMAIN_PREFIX}previous")
- .disabled = {disable_previous};
- document.getElementById("{DOMAIN_PREFIX}next")
- .disabled = {disable_next};
- }}, 1);
- }});
+ self.run_javascript_after_toolbar_ready(f"""
+ document.getElementById("{DOMAIN_PREFIX}previous")
+ .disabled = {to_js(disable_previous)};
+ document.getElementById("{DOMAIN_PREFIX}next")
+ .disabled = {to_js(disable_next)};
""")
##########################################################################
diff --git a/plugin/util.py b/plugin/util.py
index cc3c157..3ae5eb1 100644
--- a/plugin/util.py
+++ b/plugin/util.py
@@ -14,6 +14,7 @@
# along with this program. If not, see .
import os
+import sys
import anki
import anki.sync
@@ -83,3 +84,10 @@ def setting(key):
return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key])
except:
raise Exception('setting {} not found'.format(key))
+
+
+# see https://github.com/FooSoft/anki-connect/issues/308
+# fixed in https://github.com/ankitects/anki/commit/0b2a226d
+def patch_anki_2_1_50_having_null_stdout_on_windows():
+ if sys.stdout is None:
+ sys.stdout = open(os.devnull, "w", encoding="utf8")
diff --git a/tests/conftest.py b/tests/conftest.py
index 9fbbcea..208abe2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,15 +5,20 @@ from dataclasses import dataclass
import aqt.operations.note
import pytest
-from PyQt5 import QtTest
+import anki.collection
from _pytest.monkeypatch import MonkeyPatch # noqa
from pytest_anki._launch import anki_running, temporary_user # noqa
from waitress import wasyncore
-from plugin import AnkiConnect
+from plugin import AnkiConnect, anki_version
from plugin.edit import Edit
from plugin.util import DEFAULT_CONFIG
+try:
+ from PyQt6 import QtTest
+except ImportError:
+ from PyQt5 import QtTest
+
ac = AnkiConnect()
@@ -77,22 +82,33 @@ def waitress_patched_to_prevent_it_from_dying():
yield
+@contextmanager
+def anki_patched_to_prevent_backups():
+ with MonkeyPatch().context() as monkey:
+ if anki_version < (2, 1, 50):
+ monkey.setitem(aqt.profiles.profileConf, "numBackups", 0)
+ else:
+ monkey.setattr(anki.collection.Collection, "create_backup",
+ lambda *args, **kwargs: True)
+ yield
+
+
@contextmanager
def empty_anki_session_started():
with waitress_patched_to_prevent_it_from_dying():
- with anki_running(
- qtbot=None, # noqa
- enable_web_debugging=False,
- profile_name="test_user",
- ) as session:
- yield session
+ with anki_patched_to_prevent_backups():
+ with anki_running(
+ qtbot=None, # noqa
+ enable_web_debugging=False,
+ profile_name="test_user",
+ ) as session:
+ yield session
@contextmanager
def profile_created_and_loaded(session):
with temporary_user(session.base, "test_user", "en_US"):
with session.profile_loaded():
- aqt.mw.pm.profile["numBackups"] = 0 # don't try to do backups
yield session
diff --git a/tests/test_decks.py b/tests/test_decks.py
index 04e6107..c388b03 100755
--- a/tests/test_decks.py
+++ b/tests/test_decks.py
@@ -30,10 +30,6 @@ def test_deleteDeck(setup):
assert {*before} - {*after} == {"test_deck"}
-@pytest.mark.skipif(
- condition=ac._anki21_version < 28,
- reason=f"Not applicable to Anki < 2.1.28"
-)
def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup):
with pytest.raises(Exception):
ac.deleteDecks(decks=["test_deck"])
diff --git a/tests/test_edit.py b/tests/test_edit.py
index 6886fa8..71b1615 100644
--- a/tests/test_edit.py
+++ b/tests/test_edit.py
@@ -1,8 +1,63 @@
+from dataclasses import dataclass
+from unittest.mock import MagicMock
+
import aqt.operations.note
import pytest
-from conftest import get_dialog_instance
-from plugin.edit import Edit, DecentPreviewer, history
+from conftest import get_dialog_instance, wait_until
+from plugin.edit import Edit, DecentPreviewer, history, DOMAIN_PREFIX
+
+
+NOTHING = object()
+
+
+class Value:
+ def __init__(self):
+ self.value = NOTHING
+
+ def set(self, value):
+ self.value = value
+
+ def has_been_set(self):
+ return self.value is not NOTHING
+
+
+@dataclass
+class JavascriptDialogButtonManipulator:
+ dialog: ...
+
+ def eval_js(self, js):
+ evaluation_result = Value()
+ self.dialog.editor.web.evalWithCallback(js, evaluation_result.set)
+ wait_until(evaluation_result.has_been_set)
+ return evaluation_result.value
+
+ def wait_until_toolbar_buttons_are_ready(self):
+ ready_flag = Value()
+ self.dialog.editor._links["set_ready_flag"] = ready_flag.set # noqa
+ self.dialog.run_javascript_after_toolbar_ready("pycmd('set_ready_flag');")
+ wait_until(ready_flag.has_been_set)
+
+ # preview button doesn't have an id, so find by label
+ def click_preview_button(self):
+ self.eval_js("""
+ document.evaluate("//button[text()='Preview']", document)
+ .iterateNext()
+ .click()
+ """)
+
+ def click_button(self, button_id):
+ self.eval_js(f"""
+ document.getElementById("{DOMAIN_PREFIX}{button_id}").click()
+ """)
+
+ def is_button_disabled(self, button_id):
+ return self.eval_js(f"""
+ document.getElementById("{DOMAIN_PREFIX}{button_id}").disabled
+ """)
+
+
+##############################################################################
def test_edit_dialog_opens(setup):
@@ -99,6 +154,30 @@ class TestPreviewDialog:
assert dialog._should_enable_next() is next_enabled
+class TestButtons:
+ @pytest.fixture
+ def manipulator(self, setup):
+ dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
+ return JavascriptDialogButtonManipulator(dialog)
+
+ def test_preview_button_can_be_clicked(self, manipulator, monkeypatch):
+ monkeypatch.setattr(manipulator.dialog, "show_preview", MagicMock())
+ manipulator.wait_until_toolbar_buttons_are_ready()
+ manipulator.click_preview_button()
+ wait_until(lambda: manipulator.dialog.show_preview.call_count == 1)
+
+ def test_addon_buttons_can_be_clicked(self, manipulator):
+ manipulator.wait_until_toolbar_buttons_are_ready()
+ manipulator.click_button(button_id="browse")
+ wait_until(lambda: get_dialog_instance("Browser") is not None)
+
+ def test_addon_buttons_get_disabled_enabled(self, setup, manipulator):
+ Edit.open_dialog_and_show_note_with_id(setup.note2_id)
+ manipulator.wait_until_toolbar_buttons_are_ready()
+ assert manipulator.is_button_disabled("previous") is False
+ assert manipulator.is_button_disabled("next") is True
+
+
class TestHistory:
@pytest.fixture(autouse=True)
def cleanup(self):
diff --git a/tests/test_misc.py b/tests/test_misc.py
index b5feaa7..16c8e5a 100755
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1,4 +1,7 @@
+import os
+
import aqt
+import pytest
from conftest import ac, anki_connect_config_loaded, \
set_up_test_deck_and_test_model_and_two_notes, \
@@ -33,6 +36,19 @@ class TestProfiles:
class TestExportImport:
+ # since Anki 2.1.50, exporting media for some wild reason
+ # will change the current working directory, which then gets removed.
+ # see `exporting.py`, ctrl-f `os.chdir(self.mediaDir)`
+ @pytest.fixture(autouse=True)
+ def current_working_directory_preserved(self):
+ cwd = os.getcwd()
+ yield
+
+ try:
+ os.getcwd()
+ except FileNotFoundError:
+ os.chdir(cwd)
+
def test_exportPackage(self, session_with_profile_loaded, setup):
filename = session_with_profile_loaded.base + "/export.apkg"
ac.exportPackage(deck="test_deck", path=filename)
diff --git a/tox.ini b/tox.ini
index 96e56f5..a3def46 100644
--- a/tox.ini
+++ b/tox.ini
@@ -40,25 +40,26 @@
# LIBGL_ALWAYS_INDIRECT=1
# QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu"
# QT_DEBUG_PLUGINS=1
-# ANKIDEV=true
+# ANKIDEV=1
[tox]
minversion = 3.24
skipsdist = true
skip_install = true
-envlist = py38-anki{45,46,47,48,49}
+envlist = py38-anki{45,46,47,48,49},py39-anki{50qt5,50qt6}
[testenv]
commands =
- xvfb-run python -m pytest {posargs}
+ env HOME={envtmpdir}/home xvfb-run python -m pytest {posargs}
setenv =
- HOME={envdir}/home
+ anki50qt6: DISABLE_QT5_COMPAT=1
allowlist_externals =
+ env
xvfb-run
deps =
pytest==7.1.1
pytest-forked==1.4.0
- pytest-anki @ git+https://github.com/oakkitten/pytest-anki.git@17d19043
+ pytest-anki @ git+https://github.com/oakkitten/pytest-anki.git@a0d27aa5
anki45: anki==2.1.45
anki45: aqt==2.1.45
@@ -73,4 +74,10 @@ deps =
anki48: aqt==2.1.48
anki49: anki==2.1.49
- anki49: aqt==2.1.49
\ No newline at end of file
+ anki49: aqt==2.1.49
+
+ anki50qt5: anki==2.1.50
+ anki50qt5: aqt[qt5]==2.1.50
+
+ anki50qt6: anki==2.1.50
+ anki50qt6: aqt[qt6]==2.1.50