Skip to content

Commit

Permalink
Merge pull request #184 from aplaice/export_2.1.55
Browse files Browse the repository at this point in the history
Allow CrowdAnki export for the default 2.1.55+ exporter
  • Loading branch information
aplaice authored Jan 15, 2023
2 parents 0696e81 + 229aa7f commit e9f5134
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 27 deletions.
58 changes: 58 additions & 0 deletions crowd_anki/anki/compat/exporting.py
Original file line number Diff line number Diff line change
@@ -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
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 @@
"""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.
"""
146 changes: 125 additions & 21 deletions crowd_anki/export/anki_exporter_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
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
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.
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
Expand All @@ -27,42 +47,126 @@ 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):
"""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)
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 e9f5134

Please sign in to comment.