Skip to content

Commit

Permalink
Allow CrowdAnki export for the default 2.1.55+ exporter
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aplaice committed Jan 3, 2023
1 parent 0696e81 commit 22ebfe2
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 27 deletions.
56 changes: 56 additions & 0 deletions crowd_anki/anki/compat/exporting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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
6 changes: 4 additions & 2 deletions crowd_anki/anki/hook_vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions crowd_anki/anki/overrides/exporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
11 changes: 11 additions & 0 deletions crowd_anki/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CrowdAnkiException(Exception):
"""Base class for CrowdAnki's exceptions."""
pass

class UnexportableDeckException(CrowdAnkiException):
"""Exception for decks that are not CrowdAnki-exportable.
This is currently the set of all decks and filtered decks.
"""
pass
143 changes: 122 additions & 21 deletions crowd_anki/export/anki_exporter_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
from __future__ import annotations

from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING

# from aqt.import_export.exporting import Exporter, ExportOptions

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
from ..config.config_settings import ConfigSettings
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.
"""

key = "CrowdAnki JSON representation"
key = EXPORT_KEY
ext = constants.ANKI_EXPORT_EXTENSION
hideTags = True
includeTags = True
Expand All @@ -27,42 +46,124 @@ 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()

# 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):
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):
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)
18 changes: 17 additions & 1 deletion test/export/anki_exporter_wrapper_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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()

0 comments on commit 22ebfe2

Please sign in to comment.