From 98d87fd36a77a613f50140d51afa202cf67ac8ab Mon Sep 17 00:00:00 2001 From: RumovZ Date: Mon, 25 Apr 2022 08:32:38 +0200 Subject: [PATCH] Expose new apkg and colpkg exporting --- pylib/anki/collection.py | 28 ++- pylib/anki/exporting.py | 2 +- qt/aqt/browser/browser.py | 11 +- qt/aqt/import_export/__init__.py | 0 qt/aqt/import_export/exporting.py | 219 ++++++++++++++++++ .../importing.py} | 0 qt/aqt/importing.py | 2 +- qt/aqt/main.py | 8 +- 8 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 qt/aqt/import_export/__init__.py create mode 100644 qt/aqt/import_export/exporting.py rename qt/aqt/{import_export.py => import_export/importing.py} (100%) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 0b7b819e5..a8c090855 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -12,6 +12,7 @@ from anki import ( generic_pb2, import_export_pb2, links_pb2, + notes_pb2, search_pb2, stats_pb2, ) @@ -34,6 +35,9 @@ BrowserRow = search_pb2.BrowserRow BrowserColumns = search_pb2.BrowserColumns StripHtmlMode = card_rendering_pb2.StripHtmlRequest ImportLogWithChanges = import_export_pb2.ImportAnkiPackageResponse +ExportAnkiPackageRequest = import_export_pb2.ExportAnkiPackageRequest +Empty = generic_pb2.Empty +NoteIds = notes_pb2.NoteIds import copy import os @@ -356,7 +360,7 @@ class Collection(DeprecatedNamesMixin): "Throws if backup creation failed." self._backend.await_backup_completion() - def export_collection( + def export_collection_package( self, out_path: str, include_media: bool, legacy: bool ) -> None: self.close_for_full_sync() @@ -367,6 +371,28 @@ class Collection(DeprecatedNamesMixin): def import_anki_package(self, path: str) -> ImportLogWithChanges: return self._backend.import_anki_package(package_path=path) + def export_anki_package( + self, + *, + out_path: str, + selector: DeckId | list[NoteId] | None, + with_scheduling: bool, + with_media: bool, + ) -> int: + if selector is None: + selector_kwarg: dict[str, Any] = {"whole_collection": Empty()} + elif isinstance(selector, list): + selector_kwarg = {"note_ids": NoteIds(note_ids=selector)} + else: + selector_kwarg = {"deck_id": selector} + request = ExportAnkiPackageRequest( + out_path=out_path, + with_scheduling=with_scheduling, + with_media=with_media, + **selector_kwarg, + ) + return self._backend.export_anki_package(request) + # Object helpers ########################################################################## diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py index 8e5954d84..b17b783f6 100644 --- a/pylib/anki/exporting.py +++ b/pylib/anki/exporting.py @@ -446,7 +446,7 @@ class AnkiCollectionPackageExporter(AnkiPackageExporter): time.sleep(0.1) threading.Thread(target=progress).start() - self.col.export_collection(path, self.includeMedia, self.LEGACY) + self.col.export_collection_package(path, self.includeMedia, self.LEGACY) class AnkiCollectionPackage21bExporter(AnkiCollectionPackageExporter): diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index ef996641c..e6ba9c0e0 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -23,7 +23,8 @@ from anki.tags import MARKED_TAG from anki.utils import is_mac from aqt import AnkiQt, gui_hooks from aqt.editor import Editor -from aqt.exporting import ExportDialog +from aqt.exporting import ExportDialog as LegacyExportDialog +from aqt.import_export.exporting import ExportDialog from aqt.operations.card import set_card_deck, set_card_flag from aqt.operations.collection import redo, undo from aqt.operations.note import remove_notes @@ -792,8 +793,12 @@ class Browser(QMainWindow): @no_arg_trigger @skip_if_selection_is_empty def _on_export_notes(self) -> None: - cids = self.selectedNotesAsCards() - ExportDialog(self.mw, cids=list(cids)) + if os.getenv("ANKI_BACKEND_IMPORT_EXPORT"): + nids = self.selected_notes() + ExportDialog(self.mw, nids=nids) + else: + cids = self.selectedNotesAsCards() + LegacyExportDialog(self.mw, cids=list(cids)) # Flags & Marking ###################################################################### diff --git a/qt/aqt/import_export/__init__.py b/qt/aqt/import_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py new file mode 100644 index 000000000..d50ab6dac --- /dev/null +++ b/qt/aqt/import_export/exporting.py @@ -0,0 +1,219 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import os +import re +import time +from abc import ABC, abstractmethod +from concurrent.futures import Future +from dataclasses import dataclass +from typing import Sequence, Type + +import aqt.forms +import aqt.main +from anki.decks import DeckId, DeckNameId +from anki.notes import NoteId +from aqt import gui_hooks +from aqt.errors import show_exception +from aqt.operations import QueryOpWithBackendProgress +from aqt.qt import * +from aqt.utils import ( + checkInvalidFilename, + disable_help_button, + getSaveFile, + showWarning, + tooltip, + tr, +) + + +class ExportDialog(QDialog): + def __init__( + self, + mw: aqt.main.AnkiQt, + did: DeckId | None = None, + nids: Sequence[NoteId] | None = None, + ): + QDialog.__init__(self, mw, Qt.WindowType.Window) + self.mw = mw + self.col = mw.col.weakref() + self.frm = aqt.forms.exporting.Ui_ExportDialog() + self.frm.setupUi(self) + self.exporter: Type[Exporter] = None + self.nids = nids + disable_help_button(self) + self.setup(did) + self.exec() + + def setup(self, did: DeckId | None) -> None: + self.exporters: list[Type[Exporter]] = [ApkgExporter, ColpkgExporter] + self.frm.format.insertItems(0, [e.name() for e in self.exporters]) + qconnect(self.frm.format.activated, self.exporter_changed) + self.exporter_changed(0) + # deck list + if self.nids is None: + self.all_decks = self.col.decks.all_names_and_ids() + decks = [tr.exporting_all_decks()] + decks.extend(d.name for d in self.all_decks) + else: + decks = [tr.exporting_selected_notes()] + self.frm.deck.addItems(decks) + # save button + b = QPushButton(tr.exporting_export()) + self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole) + # set default option if accessed through deck button + if did: + name = self.mw.col.decks.get(did)["name"] + index = self.frm.deck.findText(name) + self.frm.deck.setCurrentIndex(index) + + def exporter_changed(self, idx: int) -> None: + self.exporter = self.exporters[idx] + self.frm.includeSched.setVisible(self.exporter.show_include_scheduling) + self.frm.includeMedia.setVisible(self.exporter.show_include_media) + self.frm.includeTags.setVisible(self.exporter.show_include_tags) + self.frm.includeHTML.setVisible(self.exporter.show_include_html) + self.frm.deck.setVisible(self.exporter.show_deck_list) + + def accept(self) -> None: + if not (out_path := self.get_out_path()): + return + self.exporter.export(self.mw, self.options(out_path)) + QDialog.reject(self) + + def get_out_path(self) -> str | None: + filename = self.filename() + while True: + path = getSaveFile( + parent=self, + title=tr.actions_export(), + dir_description="export", + key=self.exporter.name(), + ext=self.exporter.extension, + fname=filename, + ) + if not path: + return None + if checkInvalidFilename(os.path.basename(path), dirsep=False): + continue + path = os.path.normpath(path) + if os.path.commonprefix([self.mw.pm.base, path]) == self.mw.pm.base: + showWarning("Please choose a different export location.") + continue + break + return path + + def options(self, out_path: str) -> Options: + return Options( + out_path=out_path, + include_scheduling=self.frm.includeSched.isChecked(), + include_media=self.frm.includeMedia.isChecked(), + include_tags=self.frm.includeTags.isChecked(), + include_html=self.frm.includeHTML.isChecked(), + deck_id=self.current_deck_id(), + note_ids=self.nids, + ) + + def current_deck_id(self) -> DeckId | None: + return (deck := self.current_deck()) and DeckId(deck.id) or None + + def current_deck(self) -> DeckNameId | None: + if self.exporter.show_deck_list: + if idx := self.frm.deck.currentIndex(): + return self.all_decks[idx - 1] + return None + + def filename(self) -> str: + if self.exporter.show_deck_list: + deck_name = self.frm.deck.currentText() + stem = re.sub('[\\\\/?<>:*|"^]', "_", deck_name) + else: + time_str = time.strftime("%Y-%m-%d@%H-%M-%S", time.localtime(time.time())) + stem = f"{tr.exporting_collection()}-{time_str}" + return f"{stem}.{self.exporter.extension}" + + +@dataclass +class Options: + out_path: str + include_scheduling: bool + include_media: bool + include_tags: bool + include_html: bool + deck_id: DeckId | None + note_ids: Sequence[NoteId] | None + + +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 + + @staticmethod + @abstractmethod + def export(mw: aqt.main.AnkiQt, options: Options) -> None: + pass + + @staticmethod + @abstractmethod + def name() -> str: + pass + + +class ColpkgExporter(Exporter): + extension = "colpkg" + show_include_media = True + + @staticmethod + def name() -> str: + return tr.exporting_anki_collection_package() + + @staticmethod + def export(mw: aqt.main.AnkiQt, options: Options) -> None: + def on_success(_future: Future) -> None: + mw.reopen() + tooltip(tr.exporting_collection_exported()) + + def on_failure(exception: Exception) -> None: + mw.reopen() + show_exception(parent=mw, exception=exception) + + gui_hooks.collection_will_temporarily_close(mw.col) + QueryOpWithBackendProgress( + parent=mw, + op=lambda col: col.export_collection_package( + options.out_path, include_media=options.include_media, legacy=False + ), + success=on_success, + key="exporting", + ).failure(on_failure).run_in_background() + + +class ApkgExporter(Exporter): + extension = "apkg" + show_deck_list = True + show_include_scheduling = True + show_include_media = True + + @staticmethod + def name() -> str: + return tr.exporting_anki_deck_package() + + @staticmethod + def export(mw: aqt.main.AnkiQt, options: Options) -> None: + QueryOpWithBackendProgress( + parent=mw, + op=lambda col: col.export_anki_package( + out_path=options.out_path, + selector=options.note_ids or options.deck_id or None, + with_scheduling=options.include_scheduling, + with_media=options.include_media, + ), + success=lambda fut: tooltip(tr.exporting_note_exported(count=fut)), + key="exporting", + ).run_in_background() diff --git a/qt/aqt/import_export.py b/qt/aqt/import_export/importing.py similarity index 100% rename from qt/aqt/import_export.py rename to qt/aqt/import_export/importing.py diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index 359d9bf5c..4b68e0143 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -13,7 +13,7 @@ import aqt.forms import aqt.modelchooser from anki.importing.anki2 import V2ImportIntoV1 from anki.importing.apkg import AnkiPackageImporter -from aqt.import_export import import_collection_package +from aqt.import_export.importing import import_collection_package from aqt.main import AnkiQt, gui_hooks from aqt.qt import * from aqt.utils import ( diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 865d77613..3ee93547b 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -47,7 +47,8 @@ from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_ from aqt.dbcheck import check_db from aqt.emptycards import show_empty_cards from aqt.flags import FlagManager -from aqt.import_export import import_collection_package_op, import_file +from aqt.import_export.exporting import ExportDialog +from aqt.import_export.importing import import_collection_package_op, import_file from aqt.legacy import install_pylib_legacy from aqt.mediacheck import check_media_db from aqt.mediasync import MediaSyncer @@ -1191,7 +1192,10 @@ title="{}" {}>{}""".format( def onExport(self, did: DeckId | None = None) -> None: import aqt.exporting - aqt.exporting.ExportDialog(self, did=did) + if os.getenv("ANKI_BACKEND_IMPORT_EXPORT"): + ExportDialog(self, did=did) + else: + aqt.exporting.ExportDialog(self, did=did) # Installing add-ons from CLI / mimetype handler ##########################################################################