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