From 229aa7f1f3c9e1bcfbb08d4eae533bf82695e572 Mon Sep 17 00:00:00 2001 From: Adam Plaice Date: Mon, 2 Jan 2023 17:13:08 +0100 Subject: [PATCH] Allow CrowdAnki export for the default 2.1.55+ exporter Fix #169. The old, legacy exporter (Tools > Preferences > Legacy import/export handling) had worked before this commit and now still works. AFAICT compatibility with Anki 2.1.50+ has been maintained (2.1.50, 2.1.54 and 2.1.55 explicitly tested). Note that in Anki 2.1.54, CrowdAnki export is only available for the old, then default (non-beta) export method, since the hook that allows easy CrowdAnki to work with the new method was AFAICT only added in 2.1.55. --- crowd_anki/anki/compat/exporting.py | 58 ++++++++ crowd_anki/anki/hook_vendor.py | 6 +- crowd_anki/anki/overrides/exporting.py | 17 ++- crowd_anki/errors.py | 11 ++ crowd_anki/export/anki_exporter_wrapper.py | 146 ++++++++++++++++++--- test/export/anki_exporter_wrapper_spec.py | 18 ++- 6 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 crowd_anki/anki/compat/exporting.py create mode 100644 crowd_anki/errors.py diff --git a/crowd_anki/anki/compat/exporting.py b/crowd_anki/anki/compat/exporting.py new file mode 100644 index 0000000..2f18128 --- /dev/null +++ b/crowd_anki/anki/compat/exporting.py @@ -0,0 +1,58 @@ +"""Compat for Anki 2.1.54- + +This is copied verbatim from `qt/aqt/import_export/exporting.py` (in +Anki 2.1.55). We need it for our tests, which still assume that we +have Anki 2.1.26 and where the above module is missing. We can't +upgrade our dependency to Anki 2.1.55 which has the module, because +we're still using Python 3.7 with which latest Anki is incompatible. + +We could instead mock the imports from aqt.import_export.exporting +(Exporter), but given that AnkiJsonExporterWrapperNew inherits from +Exporter, this feels a bit too magical. Also, using the way in this +file, we keep compatibility for Anki 2.1.50+ (the oldest we support +atm), for a while longer. + +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from typing import Any + +import aqt.main + +ExportLimit = Any + +@dataclass +class ExportOptions: + out_path: str + include_scheduling: bool + include_media: bool + include_tags: bool + include_html: bool + include_deck: bool + include_notetype: bool + include_guid: bool + legacy_support: bool + limit: ExportLimit + +class Exporter(ABC): + extension: str + show_deck_list = False + show_include_scheduling = False + show_include_media = False + show_include_tags = False + show_include_html = False + show_legacy_support = False + show_include_deck = False + show_include_notetype = False + show_include_guid = False + + @abstractmethod + def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: + pass + + @staticmethod + @abstractmethod + def name() -> str: + pass diff --git a/crowd_anki/anki/hook_vendor.py b/crowd_anki/anki/hook_vendor.py index 99c5b28..97347d0 100644 --- a/crowd_anki/anki/hook_vendor.py +++ b/crowd_anki/anki/hook_vendor.py @@ -5,7 +5,7 @@ from ..config.config_settings import ConfigSettings from ..anki.adapters.hook_manager import AnkiHookManager -from ..export.anki_exporter_wrapper import exporters_hook +from ..export.anki_exporter_wrapper import exporters_hook, exporters_hook_new from ..history.archiver_vendor import ArchiverVendor from ..utils.deckconf import disambiguate_crowdanki_uuid @@ -22,7 +22,9 @@ def setup_hooks(self): self.setup_add_config_hook() def setup_exporter_hook(self): - self.hook_manager.hook("exportersList", exporters_hook) + self.hook_manager.hook("exportersList", exporters_hook) # 2.1.54- (and "legacy" export for 2.1.55+) + if "exporters_list_did_initialize" in dir(gui_hooks): + gui_hooks.exporters_list_did_initialize.append(exporters_hook_new) # 2.1.55+ def setup_snapshot_hooks(self): snapshot_handler = ArchiverVendor(self.window, self.config).snapshot_on_sync diff --git a/crowd_anki/anki/overrides/exporting.py b/crowd_anki/anki/overrides/exporting.py index d8e5ad7..756a11f 100644 --- a/crowd_anki/anki/overrides/exporting.py +++ b/crowd_anki/anki/overrides/exporting.py @@ -3,7 +3,12 @@ import anki.exporting import anki.hooks import anki.utils -import aqt.exporting +import aqt.exporting # Old 2.1.54- exporter +try: + import aqt.import_export.exporting # New 2.1.55+ exporter + NEW_EXPORTER_AVAILABLE = True +except ModuleNotFoundError: + NEW_EXPORTER_AVAILABLE = False import aqt.utils from aqt import QFileDialog from aqt.exporting import ExportDialog @@ -19,7 +24,8 @@ def exporter_changed(self, exporter_id): def get_save_file(parent, title, dir_description, key, ext, fname=None): - if ext == constants.ANKI_EXPORT_EXTENSION: + # Anki 2.1.55+ passes ".extension" here. Earlier versions passed just "extension". + if ext in [constants.ANKI_EXPORT_EXTENSION, "." + constants.ANKI_EXPORT_EXTENSION]: directory = str(QFileDialog.getExistingDirectory(caption="Select Export Directory", directory=fname)) if directory: @@ -32,5 +38,10 @@ def get_save_file(parent, title, dir_description, key, ext, fname=None): ExportDialog.exporterChanged = anki.hooks.wrap(ExportDialog.exporterChanged, exporter_changed) aqt.utils.getSaveFile_old = aqt.utils.getSaveFile -aqt.exporting.getSaveFile = get_save_file # Overriding instance imported with from style import + +# Overriding instance imported with from style import +aqt.exporting.getSaveFile = get_save_file # Anki 2.1.54- +if NEW_EXPORTER_AVAILABLE: + aqt.import_export.exporting.getSaveFile = get_save_file # Anki 2.1.55+ + aqt.utils.getSaveFile = get_save_file diff --git a/crowd_anki/errors.py b/crowd_anki/errors.py new file mode 100644 index 0000000..43b609a --- /dev/null +++ b/crowd_anki/errors.py @@ -0,0 +1,11 @@ +"""Module for CrowdAnki's exceptions.""" + +class CrowdAnkiException(Exception): + """Base class for CrowdAnki's exceptions.""" + +class UnexportableDeckException(CrowdAnkiException): + """Exception for decks that are not CrowdAnki-exportable. + + This is currently the set of all decks and filtered decks. + + """ diff --git a/crowd_anki/export/anki_exporter_wrapper.py b/crowd_anki/export/anki_exporter_wrapper.py index 12867ea..523f4ed 100644 --- a/crowd_anki/export/anki_exporter_wrapper.py +++ b/crowd_anki/export/anki_exporter_wrapper.py @@ -1,4 +1,20 @@ +from __future__ import annotations + from pathlib import Path +from typing import Optional +from typing import TYPE_CHECKING + +try: # Anki 2.1.55+ + from aqt.import_export.exporting import Exporter, ExportOptions +except (ModuleNotFoundError, ImportError): # Anki 2.1.54- + from ..anki.compat.exporting import Exporter, ExportOptions + +from aqt.utils import tr, tooltip + +if TYPE_CHECKING: + import aqt.main + from anki.collection import Collection + from anki.decks import DeckId from .anki_exporter import AnkiJsonExporter from ..anki.adapters.anki_deck import AnkiDeck @@ -6,16 +22,20 @@ from ..utils import constants from ..utils.notifier import AnkiModalNotifier, Notifier from ..utils.disambiguate_uuids import disambiguate_note_model_uuids +from ..errors import UnexportableDeckException EXPORT_FAILED_TITLE = "Export failed" - +EXPORT_KEY = "CrowdAnki JSON representation" # TODO make this localisable, like in Anki (tr.(...)) class AnkiJsonExporterWrapper: """ Wrapper designed to work with standard export dialog in anki. + + It works with the standard dialog for Anki 2.1.54/lower and the + legacy dialog for Anki 2.1.55/higher. """ - key = "CrowdAnki JSON representation" + key = EXPORT_KEY ext = constants.ANKI_EXPORT_EXTENSION hideTags = True includeTags = True @@ -27,7 +47,7 @@ def __init__(self, collection, notifier: Notifier = None): self.includeMedia = True self.did = deck_id - self.count = 0 # Todo? + self.count = 0 self.collection = collection self.anki_json_exporter = json_exporter or AnkiJsonExporter(collection, ConfigSettings.get_instance()) self.notifier = notifier or AnkiModalNotifier() @@ -35,34 +55,118 @@ def __init__(self, collection, # required by anki exporting interface with its non-PEP-8 names # noinspection PyPep8Naming def exportInto(self, directory_path): - if self.did is None: - self.notifier.warning(EXPORT_FAILED_TITLE, "CrowdAnki export works only for specific decks. " - "Please use CrowdAnki snapshot if you want to export " - "the whole collection.") + try: + deck = AnkiJsonExporterWrapperNew.return_deck_or_reject(self.collection, self.did, self.notifier) + except UnexportableDeckException: return - deck = AnkiDeck(self.collection.decks.get(self.did, default=False)) - if deck.is_dynamic: - self.notifier.warning(EXPORT_FAILED_TITLE, "CrowdAnki does not support export for dynamic decks.") + self.count = AnkiJsonExporterWrapperNew.clean_up_and_export( + directory_path, self.collection, deck, self.includeMedia, self.anki_json_exporter + ) + +def get_exporter_id(exporter): + return f"{exporter.key} (*{exporter.ext})", exporter + + +def exporters_hook(exporters_list): + exporter_id = get_exporter_id(AnkiJsonExporterWrapper) + if exporter_id not in exporters_list: + exporters_list.append(exporter_id) + + +class AnkiJsonExporterWrapperNew(Exporter): + """Wrapper to work with standard export dialog in anki 2.1.55+.""" + extension = constants.ANKI_EXPORT_EXTENSION + show_deck_list = True + show_include_media = True + + @staticmethod + def name() -> str: + return EXPORT_KEY + + def export(self, mw: aqt.main.AnkiQt, + options, #: ExportOptions, + anki_json_exporter: AnkiJsonExporter = None, + notifier: Notifier = None) -> None: + + def on_success(count: int) -> None: + """Display a tooltip with the number of exported notes. + + Copied from aqt/import_export/exporting.py. + + """ + # # TODO decide if we want other add-ons to be called on CrowdAnki export + # gui_hooks.exporter_did_export(options, self) + tooltip(tr.exporting_card_exported(count=count), parent=mw) + + if options.limit is None: + deck_id = None + else: + deck_id = options.limit.deck_id + + if anki_json_exporter is None: + anki_json_exporter = AnkiJsonExporter(mw.col, ConfigSettings.get_instance()) + if notifier is None: + notifier = AnkiModalNotifier() + + try: + deck = AnkiJsonExporterWrapperNew.return_deck_or_reject(mw.col, deck_id, notifier) + except UnexportableDeckException: return + count = AnkiJsonExporterWrapperNew.clean_up_and_export( + options.out_path, mw.col, deck, options.include_media, anki_json_exporter, + ) + + on_success(count) + + @staticmethod + def return_deck_or_reject(collection: Collection, + deck_id: Optional[DeckId], + notifier: Notifier) -> AnkiDeck: + + """Return deck from deck_id. Reject "all" and filtered decks.""" + if deck_id is None: + notifier.warning(EXPORT_FAILED_TITLE, "CrowdAnki export works only for specific decks. " + "Please use CrowdAnki snapshot if you want to export " + "the whole collection.") + raise UnexportableDeckException + + deck = AnkiDeck(collection.decks.get(deck_id, default=False)) + if deck.is_dynamic: + notifier.warning(EXPORT_FAILED_TITLE, "CrowdAnki does not support export for dynamic decks.") + raise UnexportableDeckException + + return deck + + @staticmethod + def clean_up_and_export(directory_path: str, + collection: Collection, + deck: AnkiDeck, + include_media: bool, + anki_json_exporter: AnkiJsonExporter) -> int: + """Clean up and do the actual export. + + Also, return the exported note count, for instance, for + displaying in a tooltip. + + """ # Clean up duplicate note models. See # https://github.com/Stvad/CrowdAnki/wiki/Workarounds-%E2%80%94-Duplicate-note-model-uuids. - disambiguate_note_model_uuids(self.collection) + disambiguate_note_model_uuids(collection) # .parent because we receive name with random numbers at the end (hacking around internals of Anki) :( export_path = Path(directory_path).parent - self.anki_json_exporter.export_to_directory(deck, export_path, self.includeMedia, - create_deck_subdirectory=ConfigSettings.get_instance().export_create_deck_subdirectory) + anki_json_exporter.export_to_directory( + deck, export_path, include_media, + create_deck_subdirectory=ConfigSettings.get_instance().export_create_deck_subdirectory + ) - self.count = self.anki_json_exporter.last_exported_count + return anki_json_exporter.last_exported_count - -def get_exporter_id(exporter): - return f"{exporter.key} (*{exporter.ext})", exporter +def exporters_hook_new(exporters_list): + """Exporter hook for Anki 2.1.55+.""" + if not AnkiJsonExporterWrapperNew in exporters_list: + exporters_list.append(AnkiJsonExporterWrapperNew) -def exporters_hook(exporters_list): - exporter_id = get_exporter_id(AnkiJsonExporterWrapper) - if exporter_id not in exporters_list: - exporters_list.append(exporter_id) diff --git a/test/export/anki_exporter_wrapper_spec.py b/test/export/anki_exporter_wrapper_spec.py index add6505..d2ff362 100644 --- a/test/export/anki_exporter_wrapper_spec.py +++ b/test/export/anki_exporter_wrapper_spec.py @@ -6,7 +6,7 @@ mock_anki_modules = MockAnkiModules(["win32file", "win32pipe", "pywintypes", "winerror"]) # Anki on Windows uses pywin32 -from crowd_anki.export.anki_exporter_wrapper import AnkiJsonExporterWrapper +from crowd_anki.export.anki_exporter_wrapper import AnkiJsonExporterWrapper, AnkiJsonExporterWrapperNew DUMMY_EXPORT_DIRECTORY = "/tmp" @@ -28,4 +28,20 @@ notifier_mock.warning.assert_called_once() exporter_mock.export_to_directory.assert_not_called() +with describe(AnkiJsonExporterWrapperNew) as self: + with context("user is trying to export dynamic deck"): + with it("should warn and exit without initiating export"): + mw_mock = MagicMock() + mw_mock.col.decks.get.return_value = {'dyn': True} + + options_mock = MagicMock() + exporter_mock = MagicMock() + notifier_mock = MagicMock() + + subject = AnkiJsonExporterWrapperNew() + subject.export(mw_mock, options_mock, exporter_mock, notifier_mock) + + notifier_mock.warning.assert_called_once() + exporter_mock.export_to_directory.assert_not_called() + mock_anki_modules.unmock()