Skip to content
This repository has been archived by the owner on Dec 31, 2023. It is now read-only.

Commit

Permalink
Merge pull request #313 from oakkitten/anki50
Browse files Browse the repository at this point in the history
Fix tests and some of the code to work with Anki 2.1.50
  • Loading branch information
FooSoft committed Apr 27, 2022
2 parents a433f60 + 8a84db9 commit fe8b221
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 82 deletions.
9 changes: 8 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
AnkiConnect.zip
meta.json
.idea/
.tox/
46 changes: 18 additions & 28 deletions plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,37 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

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


Expand All @@ -56,8 +52,6 @@
#

class AnkiConnect:
_anki21_version = int(aqt.appVersion.split('.')[-1])

def __init__(self):
self.log = None
self.timer = None
Expand All @@ -84,11 +78,7 @@ def startWebServer(self):
)

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:
Expand Down Expand Up @@ -391,7 +381,7 @@ def requestPermission(self, origin, allowed):
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:
Expand Down Expand Up @@ -547,9 +537,8 @@ def deleteDecks(self, decks, cardsToo=False):
# 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)
Expand Down Expand Up @@ -1425,9 +1414,7 @@ def openNewWindow():
if savedMid:
deck['mid'] = savedMid

addCards.editor.note = ankiNote
addCards.editor.loadNote()
addCards.editor.updateTags()
addCards.editor.set_note(ankiNote)

addCards.activateWindow()

Expand Down Expand Up @@ -1633,6 +1620,9 @@ def importPackage(self, path):
# 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()
Expand Down
106 changes: 74 additions & 32 deletions plugin/edit.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -225,8 +228,6 @@ def on_operation_did_execute(self, changes, handler):
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()

Expand Down Expand Up @@ -304,29 +305,66 @@ def setup_editor_buttons(self):
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,
Expand All @@ -338,30 +376,34 @@ def add(cmd, function, label, tip, keys):
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, "&lt;", "Previous", "Alt+Left")
add("next", self.show_next, "&gt;", "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)))

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);
}});
disable_previous = not(history.has_note_to_left_of(self.note))
disable_next = not(history.has_note_to_right_of(self.note))

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

##########################################################################
Expand Down
8 changes: 8 additions & 0 deletions plugin/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import os
import sys

import anki
import anki.sync
Expand Down Expand Up @@ -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")
34 changes: 25 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@

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

Expand Down Expand Up @@ -77,22 +82,33 @@ def close(self):
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


Expand Down
4 changes: 0 additions & 4 deletions tests/test_decks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading

0 comments on commit fe8b221

Please sign in to comment.