From 32c6983052d5a29cb6709166e335739a77cf85e2 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Mon, 15 Apr 2024 21:19:22 +0200 Subject: [PATCH] Migrate export dialog to web Closes #3023. --- ftl/core/exporting.ftl | 31 ++ proto/anki/frontend.proto | 20 + pylib/anki/collection.py | 14 +- qt/aqt/import_export/exporting.py | 371 +++--------------- qt/aqt/mediasrv.py | 76 +++- qt/aqt/utils.py | 2 +- qt/aqt/webview.py | 1 + qt/tools/genhooks_gui.py | 37 -- .../BackendProgressIndicator.svelte | 12 +- ts/lib/tslib/help-page.ts | 8 + ts/routes/export-page/+page.svelte | 12 + ts/routes/export-page/+page.ts | 10 + ts/routes/export-page/ContentOptions.svelte | 259 ++++++++++++ ts/routes/export-page/ExportPage.svelte | 120 ++++++ ts/routes/export-page/FileOptions.svelte | 95 +++++ ts/routes/export-page/StickyHeader.svelte | 60 +++ .../export-page/deck/[deckId]/+page.svelte | 12 + ts/routes/export-page/deck/[deckId]/+page.ts | 11 + ts/routes/export-page/export-page-base.scss | 18 + ts/routes/export-page/lib.ts | 137 +++++++ ts/routes/export-page/notes/+page.svelte | 12 + ts/routes/export-page/notes/+page.ts | 10 + ts/routes/export-page/types.ts | 57 +++ 23 files changed, 1012 insertions(+), 373 deletions(-) create mode 100644 ts/routes/export-page/+page.svelte create mode 100644 ts/routes/export-page/+page.ts create mode 100644 ts/routes/export-page/ContentOptions.svelte create mode 100644 ts/routes/export-page/ExportPage.svelte create mode 100644 ts/routes/export-page/FileOptions.svelte create mode 100644 ts/routes/export-page/StickyHeader.svelte create mode 100644 ts/routes/export-page/deck/[deckId]/+page.svelte create mode 100644 ts/routes/export-page/deck/[deckId]/+page.ts create mode 100644 ts/routes/export-page/export-page-base.scss create mode 100644 ts/routes/export-page/lib.ts create mode 100644 ts/routes/export-page/notes/+page.svelte create mode 100644 ts/routes/export-page/notes/+page.ts create mode 100644 ts/routes/export-page/types.ts 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}
diff --git a/ts/lib/tslib/help-page.ts b/ts/lib/tslib/help-page.ts index e1aa921dfb0..611489fbcb2 100644 --- a/ts/lib/tslib/help-page.ts +++ b/ts/lib/tslib/help-page.ts @@ -44,5 +44,13 @@ export const HelpPage = { root: "https://docs.ankiweb.net/importing/text-files.html", updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating", html: "https://docs.ankiweb.net/importing/text-files.html#html", + notetypeColumn: "https://docs.ankiweb.net/importing/text-files.html#notetype-column", + deckColumn: "https://docs.ankiweb.net/importing/text-files.html#deck-column", + guidColumn: "https://docs.ankiweb.net/importing/text-files.html#guid-column", + }, + Exporting: { + root: "https://docs.ankiweb.net/exporting.html", + packagedDecks: "https://docs.ankiweb.net/exporting.html#packaged-decks", + textFiles: "https://docs.ankiweb.net/exporting.html#text-files", }, }; diff --git a/ts/routes/export-page/+page.svelte b/ts/routes/export-page/+page.svelte new file mode 100644 index 00000000000..98f7e1ce7a8 --- /dev/null +++ b/ts/routes/export-page/+page.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/ts/routes/export-page/+page.ts b/ts/routes/export-page/+page.ts new file mode 100644 index 00000000000..7bce8361423 --- /dev/null +++ b/ts/routes/export-page/+page.ts @@ -0,0 +1,10 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { getDeckNames } from "@generated/backend"; + +import type { PageLoad } from "./$types"; + +export const load = (async (_) => { + const deckNames = await getDeckNames({ skipEmptyDefault: false, includeFiltered: true }); + return { deckNames }; +}) satisfies PageLoad; diff --git a/ts/routes/export-page/ContentOptions.svelte b/ts/routes/export-page/ContentOptions.svelte new file mode 100644 index 00000000000..090b104d043 --- /dev/null +++ b/ts/routes/export-page/ContentOptions.svelte @@ -0,0 +1,259 @@ + + + + + { + modal = e.detail.modal; + carousel = e.detail.carousel; + }} + /> + + {#if exporter.showDeckList} + + + openHelpModal(Object.keys(settings).indexOf("includeScheduling"))} + > + {tr.browsingNotes()} + + + {/if} + + {#if exporter.showIncludeScheduling} + + + openHelpModal(Object.keys(settings).indexOf("includeScheduling"))} + > + {settings.includeScheduling.title} + + + {/if} + + {#if exporter.showIncludeDeckConfigs} + + + openHelpModal(Object.keys(settings).indexOf("includeDeckConfigs"))} + > + {settings.includeDeckConfigs.title} + + + {/if} + + {#if exporter.showIncludeMedia} + + + openHelpModal(Object.keys(settings).indexOf("includeMedia"))} + > + {settings.includeMedia.title} + + + {/if} + + {#if exporter.showIncludeTags} + + + openHelpModal(Object.keys(settings).indexOf("includeTags"))} + > + {settings.includeTags.title} + + + {/if} + + {#if exporter.showIncludeHtml} + + + openHelpModal(Object.keys(settings).indexOf("includeHtml"))} + > + {settings.includeHtml.title} + + + {/if} + + {#if exporter.showIncludeDeck} + + + openHelpModal(Object.keys(settings).indexOf("includeDeck"))} + > + {settings.includeDeck.title} + + + {/if} + + {#if exporter.showIncludeNotetype} + + + openHelpModal(Object.keys(settings).indexOf("includeNotetype"))} + > + {settings.includeNotetype.title} + + + {/if} + + {#if exporter.showIncludeGuid} + + + openHelpModal(Object.keys(settings).indexOf("includeGuid"))} + > + {settings.includeGuid.title} + + + {/if} + diff --git a/ts/routes/export-page/ExportPage.svelte b/ts/routes/export-page/ExportPage.svelte new file mode 100644 index 00000000000..d12e07a1953 --- /dev/null +++ b/ts/routes/export-page/ExportPage.svelte @@ -0,0 +1,120 @@ + + + +{#if error} + +{:else if outPath} + +{:else} + + + + + + {#if exporter} + + + + {/if} + +{/if} + + diff --git a/ts/routes/export-page/FileOptions.svelte b/ts/routes/export-page/FileOptions.svelte new file mode 100644 index 00000000000..c330ed77a1a --- /dev/null +++ b/ts/routes/export-page/FileOptions.svelte @@ -0,0 +1,95 @@ + + + + + { + modal = e.detail.modal; + carousel = e.detail.carousel; + }} + /> + + openHelpModal(Object.keys(settings).indexOf("format"))} + > + {settings.format.title} + + + + {#if exporter.showLegacySupport} + + + openHelpModal(Object.keys(settings).indexOf("legacySupport"))} + > + {settings.legacySupport.title} + + + {/if} + diff --git a/ts/routes/export-page/StickyHeader.svelte b/ts/routes/export-page/StickyHeader.svelte new file mode 100644 index 00000000000..a40ac10e517 --- /dev/null +++ b/ts/routes/export-page/StickyHeader.svelte @@ -0,0 +1,60 @@ + + + + + + diff --git a/ts/routes/export-page/deck/[deckId]/+page.svelte b/ts/routes/export-page/deck/[deckId]/+page.svelte new file mode 100644 index 00000000000..8b9628a539a --- /dev/null +++ b/ts/routes/export-page/deck/[deckId]/+page.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/ts/routes/export-page/deck/[deckId]/+page.ts b/ts/routes/export-page/deck/[deckId]/+page.ts new file mode 100644 index 00000000000..2acfb4f07bd --- /dev/null +++ b/ts/routes/export-page/deck/[deckId]/+page.ts @@ -0,0 +1,11 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { getDeckNames } from "@generated/backend"; + +import type { PageLoad } from "./$types"; + +export const load = (async ({ params }) => { + const deckNames = await getDeckNames({ skipEmptyDefault: false, includeFiltered: true }); + console.log(BigInt(params.deckId)); + return { deckId: BigInt(params.deckId), deckNames }; +}) satisfies PageLoad; diff --git a/ts/routes/export-page/export-page-base.scss b/ts/routes/export-page/export-page-base.scss new file mode 100644 index 00000000000..fe96efcac90 --- /dev/null +++ b/ts/routes/export-page/export-page-base.scss @@ -0,0 +1,18 @@ +@use "../lib/sass/bootstrap-dark"; + +@import "../lib/sass/base"; + +@import "../lib/sass/bootstrap-tooltip"; +@import "bootstrap/scss/buttons"; + +.night-mode { + @include bootstrap-dark.night-mode; +} + +body { + padding: 0 1em 1em 1em; +} + +html { + height: initial; +} diff --git a/ts/routes/export-page/lib.ts b/ts/routes/export-page/lib.ts new file mode 100644 index 00000000000..a2e3439fb10 --- /dev/null +++ b/ts/routes/export-page/lib.ts @@ -0,0 +1,137 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { Empty } from "@generated/anki/generic_pb"; +import { ExportAnkiPackageOptions, ExportLimit } from "@generated/anki/import_export_pb"; +import { NoteIds } from "@generated/anki/notes_pb"; +import { + exportAnkiPackage, + exportCardCsv, + exportNoteCsv, + showTooltip, + temporarilyCloseAndExportCollectionPackage, +} from "@generated/backend"; +import * as tr from "@generated/ftl"; + +import type { Exporter, ExportOptions } from "./types"; + +export function getExporters(withLimit: boolean): Exporter[] { + return [ + createExporter("colpkg", tr.exportingAnkiCollectionPackage(), { + showIncludeMedia: true, + showLegacySupport: true, + isDefault: !withLimit, + }, _exportCollectionPackage), + createExporter("apkg", tr.exportingAnkiDeckPackage(), { + showDeckList: true, + showIncludeScheduling: true, + showIncludeDeckConfigs: true, + showIncludeMedia: true, + showLegacySupport: true, + isDefault: withLimit, + }, _exportAnkiPackage), + createExporter("txt", tr.exportingNotesInPlainText(), { + showDeckList: true, + showIncludeTags: true, + showIncludeHtml: true, + showIncludeDeck: true, + showIncludeNotetype: true, + showIncludeGuid: true, + }, _exportNoteCsv), + createExporter("txt", tr.exportingCardsInPlainText(), { + showDeckList: true, + showIncludeHtml: true, + }, _exportCardCsv), + ]; +} + +export function createExportLimit(deckIdOrNoteIds: bigint | bigint[] | null): ExportLimit { + if (deckIdOrNoteIds === null) { + return new ExportLimit({ + limit: { + case: "wholeCollection", + value: new Empty(), + }, + }); + } + if (Array.isArray(deckIdOrNoteIds)) { + return new ExportLimit({ + limit: { + case: "noteIds", + value: new NoteIds({ noteIds: deckIdOrNoteIds }), + }, + }); + } + return new ExportLimit({ + limit: { case: "deckId", value: deckIdOrNoteIds }, + }); +} + +function createExporter( + extension: string, + label: string, + options: Partial, + doExport: (outPath: string, limit: ExportLimit, options: ExportOptions) => Promise, +): Exporter { + return { + extension, + label, + showDeckList: options.showDeckList ?? false, + showIncludeScheduling: options.showIncludeScheduling ?? false, + showIncludeDeckConfigs: options.showIncludeDeckConfigs ?? false, + showIncludeMedia: options.showIncludeMedia ?? false, + showIncludeTags: options.showIncludeTags ?? false, + showIncludeHtml: options.showIncludeHtml ?? false, + showLegacySupport: options.showLegacySupport ?? false, + showIncludeDeck: options.showIncludeDeck ?? false, + showIncludeNotetype: options.showIncludeNotetype ?? false, + showIncludeGuid: options.showIncludeGuid ?? false, + isDefault: options.isDefault ?? false, + doExport, + }; +} + +async function _exportCollectionPackage(outPath: string, limit: ExportLimit, options: ExportOptions) { + await temporarilyCloseAndExportCollectionPackage({ + outPath, + includeMedia: options.includeMedia, + legacy: options.legacySupport, + }); + showTooltip({ val: tr.exportingCollectionExported() }); +} + +async function _exportAnkiPackage(outPath: string, limit: ExportLimit, options: ExportOptions) { + const result = await exportAnkiPackage({ + outPath, + limit, + options: new ExportAnkiPackageOptions({ + withScheduling: options.includeScheduling, + withDeckConfigs: options.includeDeckConfigs, + withMedia: options.includeMedia, + legacy: options.legacySupport, + }), + }); + showTooltip({ val: tr.exportingNoteExported({ count: result.val }) }); +} + +async function _exportNoteCsv(outPath: string, limit: ExportLimit, options: ExportOptions) { + const result = await exportNoteCsv({ + outPath, + limit, + withHtml: options.includeHtml, + withTags: options.includeTags, + withDeck: options.includeDeck, + withNotetype: options.includeNotetype, + withGuid: options.includeGuid, + }); + showTooltip({ val: tr.exportingNoteExported({ count: result.val }) }); +} + +async function _exportCardCsv(outPath: string, limit: ExportLimit, options: ExportOptions) { + const result = await exportCardCsv({ + outPath, + limit, + withHtml: options.includeHtml, + }); + showTooltip({ val: tr.exportingCardExported({ count: result.val }) }); +} diff --git a/ts/routes/export-page/notes/+page.svelte b/ts/routes/export-page/notes/+page.svelte new file mode 100644 index 00000000000..345a3bdfd3c --- /dev/null +++ b/ts/routes/export-page/notes/+page.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/ts/routes/export-page/notes/+page.ts b/ts/routes/export-page/notes/+page.ts new file mode 100644 index 00000000000..c67ece77040 --- /dev/null +++ b/ts/routes/export-page/notes/+page.ts @@ -0,0 +1,10 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { getNotesToExport } from "@generated/backend"; + +import type { PageLoad } from "./$types"; + +export const load = (async (_) => { + const noteIds = await getNotesToExport({}); + return { noteIds }; +}) satisfies PageLoad; diff --git a/ts/routes/export-page/types.ts b/ts/routes/export-page/types.ts new file mode 100644 index 00000000000..47b7e899344 --- /dev/null +++ b/ts/routes/export-page/types.ts @@ -0,0 +1,57 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { ExportLimit } from "@generated/anki/import_export_pb"; + +export interface Exporter { + extension: string; + label: string; + showDeckList: boolean; + showIncludeScheduling: boolean; + showIncludeDeckConfigs: boolean; + showIncludeMedia: boolean; + showIncludeTags: boolean; + showIncludeHtml: boolean; + showLegacySupport: boolean; + showIncludeDeck: boolean; + showIncludeNotetype: boolean; + showIncludeGuid: boolean; + isDefault: boolean; + doExport: (outPath: string, limit: ExportLimit, options: ExportOptions) => Promise; +} + +export interface Limit { + extension: string; + label: string; + showDeckList: boolean; + showIncludeScheduling: boolean; + showIncludeDeckConfigs: boolean; + showIncludeMedia: boolean; + showIncludeTags: boolean; + showIncludeHtml: boolean; + showLegacySupport: boolean; + showIncludeDeck: boolean; + showIncludeNotetype: boolean; + showIncludeGuid: boolean; + isDefault: boolean; +} + +export type LimitValue = bigint | bigint[] | null; + +export interface LimitChoice { + label: string; + value: number; + limit: LimitValue; +} + +export interface ExportOptions { + includeScheduling: boolean; + includeDeckConfigs: boolean; + includeMedia: boolean; + includeTags: boolean; + includeHtml: boolean; + legacySupport: boolean; + includeDeck: boolean; + includeNotetype: boolean; + includeGuid: boolean; +}