diff --git a/ftl/core/exporting.ftl b/ftl/core/exporting.ftl index be612f7daa9..277d24c6d2b 100644 --- a/ftl/core/exporting.ftl +++ b/ftl/core/exporting.ftl @@ -11,11 +11,24 @@ exporting-export = Export... exporting-export-format = Export format: exporting-include = Include: exporting-include-html-and-media-references = Include HTML and media references +exporting-include-html-and-media-references-help = + If enabled, markup and media information will be kept. Suitable if the file is intended to be reimported by Anki + or another app that can render HTML. exporting-include-media = Include media +exporting-include-media-help = If enabled, referenced media files will be bundled. exporting-include-scheduling-information = Include scheduling information +exporting-include-scheduling-information-help = + If enabled, study data like your review history and card intervals will be exported. + Unsuitable for sharing decks with others. exporting-include-deck-configs = Include deck presets +exporting-include-deck-configs-help = + If enabled, your deck option prests will be exported. However, the default preset is *never* shared. exporting-include-tags = Include tags +exporting-include-tags-help = If enabled, an additional column with note tags will be included. exporting-support-older-anki-versions = Support older Anki versions (slower/larger files) +exporting-support-older-anki-versions-help = + If enabled, the resulting file may also be imported by some outdated Anki clients, but it will + be larger, and importing and exporting will take longer. exporting-notes-in-plain-text = Notes in Plain Text exporting-selected-notes = Selected Notes exporting-card-exported = @@ -40,5 +53,23 @@ exporting-processed-media-files = *[other] Processed { $count } media files... } exporting-include-deck = Include deck name +exporting-include-deck-help = + If enabled, an additional column with deck names will be included. This will allow + the importer to sort cards into the intended decks. exporting-include-notetype = Include notetype name +exporting-include-notetype-help = + If enabled, an additional column with notetype names will be included. This will allow + the importer to assign notes the intended notetypes. exporting-include-guid = Include unique identifier +exporting-include-guid-help = + If enabled, an additional column with unique notetype identifiers will be included. + This allows to identify and update the exact orignal notes when the file is later reimported. +exporting-format = Format +exporting-content = Content +exporting-format-help = + Anki supports multiple file formats for differing use cases: + + - `{ exporting-anki-collection-package }`: Contains your entire collection. Useful for back-ups or moving between devices. + - `{ exporting-anki-deck-package }`: Lets you control exactly which notes and what data to include. Ideal for sharing decks with other users. + - `{ exporting-notes-in-plain-text }`: Converts notes into the universal CSV format, readable by many third-party tools like text editors or spreadsheet apps. + - `{ exporting-cards-in-plain-text }`: Converts the rendered front and back sides of cards into CSV format. diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 7c69429ab69..fc4c17f61a2 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -10,6 +10,8 @@ package anki.frontend; import "anki/scheduler.proto"; import "anki/generic.proto"; import "anki/search.proto"; +import "anki/notes.proto"; +import "anki/import_export.proto"; service FrontendService { // Returns values from the reviewer @@ -22,6 +24,18 @@ service FrontendService { rpc ImportDone(generic.Empty) returns (generic.Empty); rpc SearchInBrowser(search.SearchNode) returns (generic.Empty); + + // Get an export location chosen by the user + rpc GetExportFilePath(ExportFilePathRequest) returns (generic.String); + + // Retrieve note ids to include in export file + rpc GetNotesToExport(generic.Empty) returns (notes.NoteIds); + + // Show a tooltip in the main window + rpc ShowTooltip(generic.String) returns (generic.Empty); + + rpc TemporarilyCloseAndExportCollectionPackage( + import_export.ExportCollectionPackageRequest) returns (generic.Empty); } service BackendFrontendService {} @@ -35,3 +49,9 @@ message SetSchedulingStatesRequest { string key = 1; scheduler.SchedulingStates states = 2; } + +message ExportFilePathRequest { + string exporter = 1; + string extension = 2; + string filename = 3; +} diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index a8d5d62b458..f4bf255e287 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -10,6 +10,7 @@ card_rendering_pb2, collection_pb2, config_pb2, + frontend_pb2, generic_pb2, image_occlusion_pb2, import_export_pb2, @@ -57,6 +58,10 @@ MediaSyncStatus = sync_pb2.MediaSyncStatusResponse FsrsItem = scheduler_pb2.FsrsItem FsrsReview = scheduler_pb2.FsrsReview +NoteIds = notes_pb2.NoteIds +String = generic_pb2.String +ExportFilePathRequest = frontend_pb2.ExportFilePathRequest +ExportCollectionPackageRequest = import_export_pb2.ExportCollectionPackageRequest import os import sys @@ -352,9 +357,12 @@ def export_collection_package( self, out_path: str, include_media: bool, legacy: bool ) -> None: self.close_for_full_sync() - self._backend.export_collection_package( - out_path=out_path, include_media=include_media, legacy=legacy - ) + try: + self._backend.export_collection_package( + out_path=out_path, include_media=include_media, legacy=legacy + ) + finally: + self.reopen() def import_anki_package( self, request: ImportAnkiPackageRequest diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py index f06d7a47aad..de7a2dfda58 100644 --- a/qt/aqt/import_export/exporting.py +++ b/qt/aqt/import_export/exporting.py @@ -4,36 +4,15 @@ from __future__ import annotations import os -import re -import time -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Optional, Sequence, Type +from typing import Optional, Sequence import aqt.forms import aqt.main -from anki.collection import ( - DeckIdLimit, - ExportAnkiPackageOptions, - ExportLimit, - NoteIdsLimit, - Progress, -) -from anki.decks import DeckId, DeckNameId +from anki.decks import DeckId from anki.notes import NoteId -from aqt import gui_hooks -from aqt.errors import show_exception -from aqt.operations import QueryOp -from aqt.progress import ProgressUpdate +from aqt import utils, webview from aqt.qt import * -from aqt.utils import ( - checkInvalidFilename, - disable_help_button, - getSaveFile, - showWarning, - tooltip, - tr, -) +from aqt.utils import checkInvalidFilename, getSaveFile, showWarning, tr class ExportDialog(QDialog): @@ -44,311 +23,57 @@ def __init__( nids: Sequence[NoteId] | None = None, parent: Optional[QWidget] = None, ): + assert mw.col QDialog.__init__(self, parent or 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: Exporter self.nids = nids - disable_help_button(self) - self.setup(did) + self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumSize(400, 300) + self.resize(800, 600) + utils.disable_help_button(self) + utils.addCloseShortcut(self) + self.web = webview.AnkiWebView(kind=webview.AnkiWebViewKind.EXPORT) + self.web.setVisible(False) + route = "notes" if self.nids else f"deck/{did}" if did else "" + self.web.load_sveltekit_page(f"export-page/{route}") + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + self.setLayout(layout) + self.setWindowTitle(tr.actions_export()) self.open() - def setup(self, did: DeckId | None) -> None: - self.exporter_classes: list[Type[Exporter]] = [ - ApkgExporter, - ColpkgExporter, - NoteCsvExporter, - CardCsvExporter, - ] - gui_hooks.exporters_list_did_initialize(self.exporter_classes) - self.frm.format.insertItems( - 0, [f"{e.name()} (.{e.extension})" for e in self.exporter_classes] - ) - qconnect(self.frm.format.activated, self.exporter_changed) - if self.nids is None and not did: - # file>export defaults to colpkg - default_exporter_idx = 1 - else: - default_exporter_idx = 0 - self.frm.format.setCurrentIndex(default_exporter_idx) - self.exporter_changed(default_exporter_idx) - # 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) - self.frm.includeHTML.setChecked(True) - # 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) - self.frm.includeSched.setChecked(False) - - def exporter_changed(self, idx: int) -> None: - self.exporter = self.exporter_classes[idx]() - self.frm.includeSched.setVisible(self.exporter.show_include_scheduling) - self.frm.include_deck_configs.setVisible( - self.exporter.show_include_deck_configs - ) - 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.includeDeck.setVisible(self.exporter.show_include_deck) - self.frm.includeNotetype.setVisible(self.exporter.show_include_notetype) - self.frm.includeGuid.setVisible(self.exporter.show_include_guid) - self.frm.legacy_support.setVisible(self.exporter.show_legacy_support) - 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)) + def reject(self) -> None: + assert self.web + self.col.set_wants_abort() + # if not self.col.db: + # # interrupted before collection could be reopened + # self.col.reopen() + self.web.cleanup() + self.web = None 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) -> ExportOptions: - limit: ExportLimit = None - if self.nids: - limit = NoteIdsLimit(self.nids) - elif current_deck_id := self.current_deck_id(): - limit = DeckIdLimit(current_deck_id) - return ExportOptions( - out_path=out_path, - include_scheduling=self.frm.includeSched.isChecked(), - include_deck_configs=self.frm.include_deck_configs.isChecked(), - include_media=self.frm.includeMedia.isChecked(), - include_tags=self.frm.includeTags.isChecked(), - include_html=self.frm.includeHTML.isChecked(), - include_deck=self.frm.includeDeck.isChecked(), - include_notetype=self.frm.includeNotetype.isChecked(), - include_guid=self.frm.includeGuid.isChecked(), - legacy_support=self.frm.legacy_support.isChecked(), - limit=limit, +def get_out_path(exporter: str, extension: str, filename: str) -> str | None: + assert aqt.mw + parent = aqt.mw.app.activeWindow() or aqt.mw + while True: + path = getSaveFile( + parent=parent, + title=tr.actions_export(), + dir_description="export", + key=exporter, + ext=f".{extension}", + fname=filename, ) - - 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 ExportOptions: - out_path: str - include_scheduling: bool - include_deck_configs: 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_deck_configs = 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 - - -class ColpkgExporter(Exporter): - extension = "colpkg" - show_include_media = True - show_legacy_support = True - - @staticmethod - def name() -> str: - return tr.exporting_anki_collection_package() - - def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: - options = gui_hooks.exporter_will_export(options, self) - - def on_success(_: None) -> None: - mw.reopen() - gui_hooks.exporter_did_export(options, self) - tooltip(tr.exporting_collection_exported(), parent=mw) - - def on_failure(exception: Exception) -> None: - mw.reopen() - show_exception(parent=mw, exception=exception) - - gui_hooks.collection_will_temporarily_close(mw.col) - QueryOp( - parent=mw, - op=lambda col: col.export_collection_package( - options.out_path, - include_media=options.include_media, - legacy=options.legacy_support, - ), - success=on_success, - ).with_backend_progress(export_progress_update).failure( - on_failure - ).run_in_background() - - -class ApkgExporter(Exporter): - extension = "apkg" - show_deck_list = True - show_include_scheduling = True - show_include_deck_configs = True - show_include_media = True - show_legacy_support = True - - @staticmethod - def name() -> str: - return tr.exporting_anki_deck_package() - - def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: - options = gui_hooks.exporter_will_export(options, self) - - def on_success(count: int) -> None: - gui_hooks.exporter_did_export(options, self) - tooltip(tr.exporting_note_exported(count=count), parent=mw) - - QueryOp( - parent=mw, - op=lambda col: col.export_anki_package( - out_path=options.out_path, - limit=options.limit, - options=ExportAnkiPackageOptions( - with_scheduling=options.include_scheduling, - with_deck_configs=options.include_deck_configs, - with_media=options.include_media, - legacy=options.legacy_support, - ), - ), - success=on_success, - ).with_backend_progress(export_progress_update).run_in_background() - - -class NoteCsvExporter(Exporter): - extension = "txt" - show_deck_list = True - show_include_html = True - show_include_tags = True - show_include_deck = True - show_include_notetype = True - show_include_guid = True - - @staticmethod - def name() -> str: - return tr.exporting_notes_in_plain_text() - - def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: - options = gui_hooks.exporter_will_export(options, self) - - def on_success(count: int) -> None: - gui_hooks.exporter_did_export(options, self) - tooltip(tr.exporting_note_exported(count=count), parent=mw) - - QueryOp( - parent=mw, - op=lambda col: col.export_note_csv( - out_path=options.out_path, - limit=options.limit, - with_html=options.include_html, - with_tags=options.include_tags, - with_deck=options.include_deck, - with_notetype=options.include_notetype, - with_guid=options.include_guid, - ), - success=on_success, - ).with_backend_progress(export_progress_update).run_in_background() - - -class CardCsvExporter(Exporter): - extension = "txt" - show_deck_list = True - show_include_html = True - - @staticmethod - def name() -> str: - return tr.exporting_cards_in_plain_text() - - def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None: - options = gui_hooks.exporter_will_export(options, self) - - def on_success(count: int) -> None: - gui_hooks.exporter_did_export(options, self) - tooltip(tr.exporting_card_exported(count=count), parent=mw) - - QueryOp( - parent=mw, - op=lambda col: col.export_card_csv( - out_path=options.out_path, - limit=options.limit, - with_html=options.include_html, - ), - success=on_success, - ).with_backend_progress(export_progress_update).run_in_background() - - -def export_progress_update(progress: Progress, update: ProgressUpdate) -> None: - if not progress.HasField("exporting"): - return - update.label = progress.exporting - if update.user_wants_abort: - update.abort = True + if not path: + return None + if checkInvalidFilename(os.path.basename(path), dirsep=False): + continue + path = os.path.normpath(path) + if os.path.commonprefix([aqt.mw.pm.base, path]) == aqt.mw.pm.base: + showWarning("Please choose a different export location.") + continue + break + return path diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 51b72e96098..18aa18bed3d 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -10,11 +10,12 @@ import re import sys import threading +import time import traceback from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus -from typing import Callable +from typing import Callable, Sequence import flask import flask_cors @@ -27,17 +28,27 @@ import aqt.main import aqt.operations from anki import hooks -from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode +from anki.collection import ( + ExportCollectionPackageRequest, + ExportFilePathRequest, + NoteIds, + OpChanges, + OpChangesOnly, + Progress, + SearchNode, + String, +) from anki.decks import UpdateDeckConfigs from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog +from aqt.import_export import exporting from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.progress import ProgressUpdate from aqt.qt import * -from aqt.utils import aqt_data_path, show_warning, tr +from aqt.utils import aqt_data_path, show_warning, tooltip, tr # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore @@ -334,6 +345,7 @@ def is_sveltekit_page(path: str) -> bool: "import-anki-package", "import-csv", "import-page", + "export-page", ] @@ -573,6 +585,56 @@ def handle_on_main() -> None: return b"" +def get_notes_to_export() -> bytes: + assert aqt.mw + note_ids: Sequence[int] = [] + + if window := aqt.mw.app.activeWindow(): + from aqt.import_export.exporting import ExportDialog + + if isinstance(window, ExportDialog) and window.nids: + note_ids = window.nids + + return NoteIds(note_ids=note_ids).SerializeToString() + + +def get_export_file_path() -> bytes: + assert aqt.mw + req = ExportFilePathRequest() + req.ParseFromString(request.data) + path = None + + def get_out_path() -> None: + nonlocal path + path = exporting.get_out_path(req.exporter, req.extension, req.filename) or "" + + aqt.mw.taskman.run_on_main(get_out_path) + while path is None: + time.sleep(0.05) + return String(val=path).SerializeToString() + + +def show_tooltip() -> bytes: + assert aqt.mw + req = String() + req.ParseFromString(request.data) + aqt.mw.taskman.run_on_main(lambda: tooltip(req.val, parent=aqt.mw)) + return b"" + + +def temporarily_close_and_export_collection_package() -> bytes: + assert aqt.mw + assert aqt.mw.col + req = ExportCollectionPackageRequest() + req.ParseFromString(request.data) + aqt.mw.col.export_collection_package( + req.out_path, + include_media=req.include_media, + legacy=req.legacy, + ) + return b"" + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -586,6 +648,10 @@ def handle_on_main() -> None: import_json_file, import_json_string, search_in_browser, + get_notes_to_export, + get_export_file_path, + show_tooltip, + temporarily_close_and_export_collection_package, ] @@ -599,6 +665,10 @@ def handle_on_main() -> None: # ImportExportService "get_csv_metadata", "get_import_anki_package_presets", + "export_collection_package", + "export_anki_package", + "export_note_csv", + "export_card_csv", # NotesService "get_field_names", "get_note", diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index d89c9fbf61b..af793639a9f 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -676,7 +676,7 @@ def running_in_sandbox(): def getSaveFile( - parent: QDialog, + parent: QWidget, title: str, dir_description: str, key: str, diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 0956426d01d..1c3b8356dbf 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -258,6 +258,7 @@ class AnkiWebViewKind(Enum): FIELDS = "fields" IMPORT_LOG = "import log" IMPORT_ANKI_PACKAGE = "anki package import" + EXPORT = "export" class AnkiWebView(QWebEngineView): diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 57c5443cf92..f9b8e332bdb 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -900,34 +900,6 @@ def on_top_toolbar_did_init_links(links, toolbar): ), # Importing/exporting data ################### - Hook( - name="exporter_will_export", - args=[ - "export_options: aqt.import_export.exporting.ExportOptions", - "exporter: aqt.import_export.exporting.Exporter", - ], - return_type="aqt.import_export.exporting.ExportOptions", - doc="""Called before collection and deck exports. - - Allows add-ons to be notified of impending deck exports and potentially - modify the export options. To perform the export unaltered, please return - `export_options` as is, e.g.: - - def on_exporter_will_export(export_options: ExportOptions, exporter: Exporter): - if not isinstance(exporter, ApkgExporter): - return export_options - export_options.limit = ... - return export_options - """, - ), - Hook( - name="exporter_did_export", - args=[ - "export_options: aqt.import_export.exporting.ExportOptions", - "exporter: aqt.import_export.exporting.Exporter", - ], - doc="""Called after collection and deck exports.""", - ), Hook( name="legacy_exporter_will_export", args=["legacy_exporter: anki.exporting.Exporter"], @@ -938,15 +910,6 @@ def on_exporter_will_export(export_options: ExportOptions, exporter: Exporter): args=["legacy_exporter: anki.exporting.Exporter"], doc="""Called after collection and deck exports performed by legacy exporters.""", ), - Hook( - name="exporters_list_did_initialize", - args=["exporters: list[Type[aqt.import_export.exporting.Exporter]]"], - doc="""Called after the list of exporter classes is created. - - Allows you to register custom exporters and/or replace existing ones by - modifying the exporter list. - """, - ), # Dialog Manager ################### Hook( diff --git a/ts/lib/components/BackendProgressIndicator.svelte b/ts/lib/components/BackendProgressIndicator.svelte index 6a5778e21f3..246bcc1ecbc 100644 --- a/ts/lib/components/BackendProgressIndicator.svelte +++ b/ts/lib/components/BackendProgressIndicator.svelte @@ -3,15 +3,15 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> -{#if !result} +{#if result === undefined}