anki/qt/aqt/import_export/exporting.py
RumovZ f200d6248e
Allow im-/exporting with or without deck configs (#2804)
* Allow im-/exporting with or without deck configs

Closes #2777.

* Enable webengine remote debugging in launch.json

* Reset deck limits and counts based on scheduling

Also:
- Fix `deck.common` not being reset.
- Apply all logic only depending on the source collection in the
gathering stage.
- Skip checking for scheduling and only act based on whether the call
wants scheduling. Preservation of filtered decks also depends on all
original decks being included.
- Fix check_ids() not covering revlog.

* Fix importing legacy filtered decks w/o scheduling

* Disable 'include deck options' by default, and fix tab order (dae)

* deck options > deck presets (dae)
2023-11-13 13:54:41 +10:00

355 lines
12 KiB
Python

# 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 dataclasses import dataclass
from typing import Optional, Sequence, Type
import aqt.forms
import aqt.main
from anki.collection import (
DeckIdLimit,
ExportAnkiPackageOptions,
ExportLimit,
NoteIdsLimit,
Progress,
)
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 QueryOp
from aqt.progress import ProgressUpdate
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,
parent: Optional[QWidget] = None,
):
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.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))
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 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