From c6f76a929df0c4e3b28e03f8f2c16023ec6d2017 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Sun, 28 Apr 2024 21:34:19 +0200 Subject: [PATCH] Make last exporting dialog the new legacy --- pylib/anki/exporting.py | 470 -------------------------- pylib/tests/test_exporting.py | 163 --------- qt/aqt/browser/browser.py | 12 +- qt/aqt/exporting.py | 226 ------------- qt/aqt/import_export/exporting.py | 368 +++++++++++++++++--- qt/aqt/import_export/exporting_web.py | 76 +++++ qt/aqt/main.py | 8 +- qt/aqt/mediasrv.py | 10 +- qt/tools/genhooks_gui.py | 39 ++- 9 files changed, 445 insertions(+), 927 deletions(-) delete mode 100644 pylib/anki/exporting.py delete mode 100644 pylib/tests/test_exporting.py delete mode 100644 qt/aqt/exporting.py create mode 100644 qt/aqt/import_export/exporting_web.py diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py deleted file mode 100644 index 1e75adfae03..00000000000 --- a/pylib/anki/exporting.py +++ /dev/null @@ -1,470 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -# pylint: disable=invalid-name - -from __future__ import annotations - -import json -import os -import re -import shutil -import threading -import time -import unicodedata -import zipfile -from io import BufferedWriter -from typing import Any, Optional, Sequence -from zipfile import ZipFile - -from anki import hooks -from anki.cards import CardId -from anki.collection import Collection -from anki.decks import DeckId -from anki.utils import ids2str, namedtmp, split_fields, strip_html - - -class Exporter: - includeHTML: bool | None = None - ext: Optional[str] = None - includeTags: Optional[bool] = None - includeSched: Optional[bool] = None - includeMedia: Optional[bool] = None - - def __init__( - self, - col: Collection, - did: Optional[DeckId] = None, - cids: Optional[list[CardId]] = None, - ) -> None: - self.col = col.weakref() - self.did = did - self.cids = cids - - @staticmethod - def key(col: Collection) -> str: - return "" - - def doExport(self, path) -> None: - raise Exception("not implemented") - - def exportInto(self, path: str) -> None: - self._escapeCount = 0 - file = open(path, "wb") - self.doExport(file) - file.close() - - def processText(self, text: str) -> str: - if self.includeHTML is False: - text = self.stripHTML(text) - - text = self.escapeText(text) - - return text - - def escapeText(self, text: str) -> str: - "Escape newlines, tabs, CSS and quotechar." - # fixme: we should probably quote fields with newlines - # instead of converting them to spaces - text = text.replace("\n", " ") - text = text.replace("\r", "") - text = text.replace("\t", " " * 8) - text = re.sub("(?i)", "", text) - text = re.sub(r"\[\[type:[^]]+\]\]", "", text) - if '"' in text or "'" in text: - text = '"' + text.replace('"', '""') + '"' - return text - - def stripHTML(self, text: str) -> str: - # very basic conversion to text - s = text - s = re.sub(r"(?i)<(br ?/?|div|p)>", " ", s) - s = re.sub(r"\[sound:[^]]+\]", "", s) - s = strip_html(s) - s = re.sub(r"[ \n\t]+", " ", s) - s = s.strip() - return s - - def cardIds(self) -> Any: - if self.cids is not None: - cids = self.cids - elif not self.did: - cids = self.col.db.list("select id from cards") - else: - cids = self.col.decks.cids(self.did, children=True) - self.count = len(cids) - return cids - - -# Cards as TSV -###################################################################### - - -class TextCardExporter(Exporter): - ext = ".txt" - includeHTML = True - - def __init__(self, col) -> None: - Exporter.__init__(self, col) - - @staticmethod - def key(col: Collection) -> str: - return col.tr.exporting_cards_in_plain_text() - - def doExport(self, file) -> None: - ids = sorted(self.cardIds()) - strids = ids2str(ids) - - def esc(s): - # strip off the repeated question in answer if exists - s = re.sub("(?si)^.*
\n*", "", s) - return self.processText(s) - - out = "" - for cid in ids: - c = self.col.get_card(cid) - out += esc(c.question()) - out += "\t" + esc(c.answer()) + "\n" - file.write(out.encode("utf-8")) - - -# Notes as TSV -###################################################################### - - -class TextNoteExporter(Exporter): - ext = ".txt" - includeTags = True - includeHTML = True - - def __init__(self, col: Collection) -> None: - Exporter.__init__(self, col) - self.includeID = False - - @staticmethod - def key(col: Collection) -> str: - return col.tr.exporting_notes_in_plain_text() - - def doExport(self, file: BufferedWriter) -> None: - cardIds = self.cardIds() - data = [] - for id, flds, tags in self.col.db.execute( - """ -select guid, flds, tags from notes -where id in -(select nid from cards -where cards.id in %s)""" - % ids2str(cardIds) - ): - row = [] - # note id - if self.includeID: - row.append(str(id)) - # fields - row.extend([self.processText(f) for f in split_fields(flds)]) - # tags - if self.includeTags: - row.append(tags.strip()) - data.append("\t".join(row)) - self.count = len(data) - out = "\n".join(data) - file.write(out.encode("utf-8")) - - -# Anki decks -###################################################################### -# media files are stored in self.mediaFiles, but not exported. - - -class AnkiExporter(Exporter): - ext = ".anki2" - includeSched: bool | None = False - includeMedia = True - - def __init__(self, col: Collection) -> None: - Exporter.__init__(self, col) - - @staticmethod - def key(col: Collection) -> str: - return col.tr.exporting_anki_20_deck() - - def deckIds(self) -> list[DeckId]: - if self.cids: - return self.col.decks.for_card_ids(self.cids) - elif self.did: - return self.src.decks.deck_and_child_ids(self.did) - else: - return [] - - def exportInto(self, path: str) -> None: - # create a new collection at the target - try: - os.unlink(path) - except OSError: - pass - self.dst = Collection(path) - self.src = self.col - # find cards - cids = self.cardIds() - # copy cards, noting used nids - nids = {} - data: list[Sequence] = [] - for row in self.src.db.execute( - "select * from cards where id in " + ids2str(cids) - ): - # clear flags - row = list(row) - row[-2] = 0 - nids[row[1]] = True - data.append(row) - self.dst.db.executemany( - "insert into cards values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", data - ) - # notes - strnids = ids2str(list(nids.keys())) - notedata = [] - for row in self.src.db.all("select * from notes where id in " + strnids): - # remove system tags if not exporting scheduling info - if not self.includeSched: - row = list(row) - row[5] = self.removeSystemTags(row[5]) - notedata.append(row) - self.dst.db.executemany( - "insert into notes values (?,?,?,?,?,?,?,?,?,?,?)", notedata - ) - # models used by the notes - mids = self.dst.db.list("select distinct mid from notes where id in " + strnids) - # card history and revlog - if self.includeSched: - data = self.src.db.all("select * from revlog where cid in " + ids2str(cids)) - self.dst.db.executemany( - "insert into revlog values (?,?,?,?,?,?,?,?,?)", data - ) - else: - # need to reset card state - self.dst.sched.reset_cards(cids) - # models - start with zero - self.dst.mod_schema(check=False) - self.dst.models.remove_all_notetypes() - for m in self.src.models.all(): - if int(m["id"]) in mids: - self.dst.models.update(m) - # decks - dids = self.deckIds() - dconfs = {} - for d in self.src.decks.all(): - if str(d["id"]) == "1": - continue - if dids and d["id"] not in dids: - continue - if not d["dyn"] and d["conf"] != 1: - if self.includeSched: - dconfs[d["conf"]] = True - if not self.includeSched: - # scheduling not included, so reset deck settings to default - d = dict(d) - d["conf"] = 1 - d["reviewLimit"] = d["newLimit"] = None - d["reviewLimitToday"] = d["newLimitToday"] = None - self.dst.decks.update(d) - # copy used deck confs - for dc in self.src.decks.all_config(): - if dc["id"] in dconfs: - self.dst.decks.update_config(dc) - # find used media - media = {} - self.mediaDir = self.src.media.dir() - if self.includeMedia: - for row in notedata: - flds = row[6] - mid = row[2] - for file in self.src.media.files_in_str(mid, flds): - # skip files in subdirs - if file != os.path.basename(file): - continue - media[file] = True - if self.mediaDir: - for fname in os.listdir(self.mediaDir): - path = os.path.join(self.mediaDir, fname) - if os.path.isdir(path): - continue - if fname.startswith("_"): - # Scan all models in mids for reference to fname - for m in self.src.models.all(): - if int(m["id"]) in mids: - if self._modelHasMedia(m, fname): - media[fname] = True - break - self.mediaFiles = list(media.keys()) - self.dst.crt = self.src.crt - # todo: tags? - self.count = self.dst.card_count() - self.postExport() - self.dst.close(downgrade=True) - - def postExport(self) -> None: - # overwrite to apply customizations to the deck before it's closed, - # such as update the deck description - pass - - def removeSystemTags(self, tags: str) -> str: - return self.src.tags.rem_from_str("marked leech", tags) - - def _modelHasMedia(self, model, fname) -> bool: - # First check the styling - if fname in model["css"]: - return True - # If no reference to fname then check the templates as well - for t in model["tmpls"]: - if fname in t["qfmt"] or fname in t["afmt"]: - return True - return False - - -# Packaged Anki decks -###################################################################### - - -class AnkiPackageExporter(AnkiExporter): - ext = ".apkg" - - def __init__(self, col: Collection) -> None: - AnkiExporter.__init__(self, col) - - @staticmethod - def key(col: Collection) -> str: - return col.tr.exporting_anki_deck_package() - - def exportInto(self, path: str) -> None: - # open a zip file - z = zipfile.ZipFile( - path, "w", zipfile.ZIP_DEFLATED, allowZip64=True, strict_timestamps=False - ) - media = self.doExport(z, path) - # media map - z.writestr("media", json.dumps(media)) - z.close() - - def doExport(self, z: ZipFile, path: str) -> dict[str, str]: # type: ignore - # export into the anki2 file - colfile = path.replace(".apkg", ".anki2") - AnkiExporter.exportInto(self, colfile) - # prevent older clients from accessing - # pylint: disable=unreachable - self._addDummyCollection(z) - z.write(colfile, "collection.anki21") - - # and media - self.prepareMedia() - media = self._exportMedia(z, self.mediaFiles, self.mediaDir) - # tidy up intermediate files - os.unlink(colfile) - p = path.replace(".apkg", ".media.db2") - if os.path.exists(p): - os.unlink(p) - shutil.rmtree(path.replace(".apkg", ".media")) - return media - - def _exportMedia(self, z: ZipFile, files: list[str], fdir: str) -> dict[str, str]: - media = {} - for c, file in enumerate(files): - cStr = str(c) - file = hooks.media_file_filter(file) - mpath = os.path.join(fdir, file) - if os.path.isdir(mpath): - continue - if os.path.exists(mpath): - if re.search(r"\.svg$", file, re.IGNORECASE): - z.write(mpath, cStr, zipfile.ZIP_DEFLATED) - else: - z.write(mpath, cStr, zipfile.ZIP_STORED) - media[cStr] = unicodedata.normalize("NFC", file) - hooks.media_files_did_export(c) - - return media - - def prepareMedia(self) -> None: - # chance to move each file in self.mediaFiles into place before media - # is zipped up - pass - - # create a dummy collection to ensure older clients don't try to read - # data they don't understand - def _addDummyCollection(self, zip) -> None: - path = namedtmp("dummy.anki2") - c = Collection(path) - n = c.newNote() - n.fields[0] = "This file requires a newer version of Anki." - c.addNote(n) - c.close(downgrade=True) - - zip.write(path, "collection.anki2") - os.unlink(path) - - -# Collection package -###################################################################### - - -class AnkiCollectionPackageExporter(AnkiPackageExporter): - ext = ".colpkg" - verbatim = True - includeSched = None - LEGACY = True - - def __init__(self, col): - AnkiPackageExporter.__init__(self, col) - - @staticmethod - def key(col: Collection) -> str: - return col.tr.exporting_anki_collection_package() - - def exportInto(self, path: str) -> None: - """Export collection. Caller must re-open afterwards.""" - - def exporting_media() -> bool: - return any( - hook.__name__ == "exported_media" - for hook in hooks.legacy_export_progress._hooks - ) - - def progress() -> None: - while exporting_media(): - progress = self.col._backend.latest_progress() - if progress.HasField("exporting"): - hooks.legacy_export_progress(progress.exporting) - time.sleep(0.1) - - threading.Thread(target=progress).start() - self.col.export_collection_package(path, self.includeMedia, self.LEGACY) - - -class AnkiCollectionPackage21bExporter(AnkiCollectionPackageExporter): - LEGACY = False - - @staticmethod - def key(_col: Collection) -> str: - return "Anki 2.1.50+ Collection Package" - - -# Export modules -########################################################################## - - -def exporters(col: Collection) -> list[tuple[str, Any]]: - def id(obj) -> tuple[str, Exporter]: - if callable(obj.key): - key_str = obj.key(col) - else: - key_str = obj.key - return (f"{key_str} (*{obj.ext})", obj) - - exps = [ - id(AnkiCollectionPackageExporter), - id(AnkiCollectionPackage21bExporter), - id(AnkiPackageExporter), - id(TextNoteExporter), - id(TextCardExporter), - ] - hooks.exporters_list_created(exps) - return exps diff --git a/pylib/tests/test_exporting.py b/pylib/tests/test_exporting.py deleted file mode 100644 index 7946f2e456c..00000000000 --- a/pylib/tests/test_exporting.py +++ /dev/null @@ -1,163 +0,0 @@ -# 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 tempfile - -from anki.collection import Collection as aopen -from anki.exporting import * -from anki.importing import Anki2Importer -from tests.shared import errorsAfterMidnight -from tests.shared import getEmptyCol as getEmptyColOrig - - -def getEmptyCol(): - col = getEmptyColOrig() - col.upgrade_to_v2_scheduler() - return col - - -col: Collection | None = None -testDir = os.path.dirname(__file__) - - -def setup1(): - global col - col = getEmptyCol() - note = col.newNote() - note["Front"] = "foo" - note["Back"] = "bar
" - note.tags = ["tag", "tag2"] - col.addNote(note) - # with a different col - note = col.newNote() - note["Front"] = "baz" - note["Back"] = "qux" - note.note_type()["did"] = col.decks.id("new col") - col.addNote(note) - - -########################################################################## - - -def test_export_anki(): - setup1() - # create a new col with its own conf to test conf copying - did = col.decks.id("test") - dobj = col.decks.get(did) - confId = col.decks.add_config_returning_id("newconf") - conf = col.decks.get_config(confId) - conf["new"]["perDay"] = 5 - col.decks.save(conf) - col.decks.set_config_id_for_deck_dict(dobj, confId) - # export - e = AnkiExporter(col) - fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") - newname = str(newname) - os.close(fd) - os.unlink(newname) - e.exportInto(newname) - # exporting should not have changed conf for original deck - conf = col.decks.config_dict_for_deck_id(did) - assert conf["id"] != 1 - # connect to new deck - col2 = aopen(newname) - assert col2.card_count() == 2 - # as scheduling was reset, should also revert decks to default conf - did = col2.decks.id("test", create=False) - assert did - conf2 = col2.decks.config_dict_for_deck_id(did) - assert conf2["new"]["perDay"] == 20 - dobj = col2.decks.get(did) - # conf should be 1 - assert dobj["conf"] == 1 - # try again, limited to a deck - fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") - newname = str(newname) - os.close(fd) - os.unlink(newname) - e.did = DeckId(1) - e.exportInto(newname) - col2 = aopen(newname) - assert col2.card_count() == 1 - - -def test_export_ankipkg(): - setup1() - # add a test file to the media folder - with open(os.path.join(col.media.dir(), "今日.mp3"), "w") as note: - note.write("test") - n = col.newNote() - n["Front"] = "[sound:今日.mp3]" - col.addNote(n) - e = AnkiPackageExporter(col) - fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".apkg") - newname = str(newname) - os.close(fd) - os.unlink(newname) - e.exportInto(newname) - - -@errorsAfterMidnight -def test_export_anki_due(): - setup1() - col = getEmptyCol() - note = col.newNote() - note["Front"] = "foo" - col.addNote(note) - col.crt -= 86400 * 10 - c = col.sched.getCard() - col.sched.answerCard(c, 3) - col.sched.answerCard(c, 3) - # should have ivl of 1, due on day 11 - assert c.ivl == 1 - assert c.due == 11 - assert col.sched.today == 10 - assert c.due - col.sched.today == 1 - # export - e = AnkiExporter(col) - e.includeSched = True - fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") - newname = str(newname) - os.close(fd) - os.unlink(newname) - e.exportInto(newname) - # importing into a new deck, the due date should be equivalent - col2 = getEmptyCol() - imp = Anki2Importer(col2, newname) - imp.run() - c = col2.getCard(c.id) - assert c.due - col2.sched.today == 1 - - -# def test_export_textcard(): -# setup1() -# e = TextCardExporter(col) -# note = unicode(tempfile.mkstemp(prefix="ankitest")[1]) -# os.unlink(note) -# e.exportInto(note) -# e.includeTags = True -# e.exportInto(note) - - -def test_export_textnote(): - setup1() - e = TextNoteExporter(col) - fd, note = tempfile.mkstemp(prefix="ankitest") - note = str(note) - os.close(fd) - os.unlink(note) - e.exportInto(note) - with open(note) as file: - assert file.readline() == "foo\tbar
\ttag tag2\n" - e.includeTags = False - e.includeHTML = False - e.exportInto(note) - with open(note) as file: - assert file.readline() == "foo\tbar\n" - - -def test_exporters(): - assert "*.apkg" in str(exporters(getEmptyCol())) diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index e8dcff326ac..b90a721a55f 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -26,8 +26,7 @@ from aqt import AnkiQt, gui_hooks from aqt.editor import Editor from aqt.errors import show_exception -from aqt.exporting import ExportDialog as LegacyExportDialog -from aqt.import_export.exporting import ExportDialog +from aqt.import_export import exporting, exporting_web 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 @@ -916,12 +915,11 @@ def bury_selected_cards(self, checked: bool) -> None: @no_arg_trigger @skip_if_selection_is_empty def _on_export_notes(self) -> None: - if not self.mw.pm.legacy_import_export(): - nids = self.selected_notes() - ExportDialog(self.mw, nids=nids, parent=self) + nids = self.selected_notes() + if not self.pm.legacy_import_export(): + exporting_web.ExportDialog(self.mw, nids=nids, parent=self) else: - cids = self.selectedNotesAsCards() - LegacyExportDialog(self.mw, cids=list(cids), parent=self) + exporting.ExportDialog(self.mw, nids=nids, parent=self) # Flags & Marking ###################################################################### diff --git a/qt/aqt/exporting.py b/qt/aqt/exporting.py deleted file mode 100644 index 149d486e6f9..00000000000 --- a/qt/aqt/exporting.py +++ /dev/null @@ -1,226 +0,0 @@ -# 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 concurrent.futures import Future -from typing import Optional - -import aqt -import aqt.forms -import aqt.main -from anki import hooks -from anki.cards import CardId -from anki.decks import DeckId -from anki.exporting import Exporter, exporters -from aqt import gui_hooks -from aqt.errors import show_exception -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, - cids: list[CardId] | 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.frm.legacy_support.setVisible(False) - self.exporter: Exporter | None = None - self.cids = cids - disable_help_button(self) - self.setup(did) - self.exec() - - def setup(self, did: DeckId | None) -> None: - self.exporters = exporters(self.col) - # if a deck specified, start with .apkg type selected - idx = 0 - if did or self.cids: - for c, (k, e) in enumerate(self.exporters): - if e.ext == ".apkg": - idx = c - break - self.frm.format.insertItems(0, [e[0] for e in self.exporters]) - self.frm.format.setCurrentIndex(idx) - qconnect(self.frm.format.activated, self.exporterChanged) - self.exporterChanged(idx) - # deck list - if self.cids is None: - self.decks = [tr.exporting_all_decks()] - self.decks.extend(d.name for d in self.col.decks.all_names_and_ids()) - else: - self.decks = [tr.exporting_selected_notes()] - self.frm.deck.addItems(self.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 exporterChanged(self, idx: int) -> None: - self.exporter = self.exporters[idx][1](self.col) - self.isApkg = self.exporter.ext == ".apkg" - self.isVerbatim = getattr(self.exporter, "verbatim", False) - self.isTextNote = getattr(self.exporter, "includeTags", False) - self.frm.includeSched.setVisible( - getattr(self.exporter, "includeSched", None) is not None - ) - self.frm.includeMedia.setVisible( - getattr(self.exporter, "includeMedia", None) is not None - ) - self.frm.includeTags.setVisible( - getattr(self.exporter, "includeTags", None) is not None - ) - html = getattr(self.exporter, "includeHTML", None) - if html is not None: - self.frm.includeHTML.setVisible(True) - self.frm.includeHTML.setChecked(html) - else: - self.frm.includeHTML.setVisible(False) - # show deck list? - self.frm.deck.setVisible(not self.isVerbatim) - # used by the new export screen - self.frm.includeDeck.setVisible(False) - self.frm.includeNotetype.setVisible(False) - self.frm.includeGuid.setVisible(False) - - def accept(self) -> None: - self.exporter.includeSched = self.frm.includeSched.isChecked() - self.exporter.includeMedia = self.frm.includeMedia.isChecked() - self.exporter.includeTags = self.frm.includeTags.isChecked() - self.exporter.includeHTML = self.frm.includeHTML.isChecked() - idx = self.frm.deck.currentIndex() - if self.cids is not None: - # Browser Selection - self.exporter.cids = self.cids - self.exporter.did = None - elif idx == 0: - # All decks - self.exporter.did = None - self.exporter.cids = None - else: - # Deck idx-1 in the list of decks - self.exporter.cids = None - name = self.decks[self.frm.deck.currentIndex()] - self.exporter.did = self.col.decks.id(name) - if self.isVerbatim: - name = time.strftime("-%Y-%m-%d@%H-%M-%S", time.localtime(time.time())) - deck_name = tr.exporting_collection() + name - else: - # Get deck name and remove invalid filename characters - deck_name = self.decks[self.frm.deck.currentIndex()] - deck_name = re.sub('[\\\\/?<>:*|"^]', "_", deck_name) - - filename = f"{deck_name}{self.exporter.ext}" - if callable(self.exporter.key): - key_str = self.exporter.key(self.col) - else: - key_str = self.exporter.key - while 1: - file = getSaveFile( - self, - tr.actions_export(), - "export", - key_str, - self.exporter.ext, - fname=filename, - ) - if not file: - return - if checkInvalidFilename(os.path.basename(file), dirsep=False): - continue - file = os.path.normpath(file) - if os.path.commonprefix([self.mw.pm.base, file]) == self.mw.pm.base: - showWarning("Please choose a different export location.") - continue - break - self.hide() - if file: - # check we can write to file - try: - f = open(file, "wb") - f.close() - except OSError as e: - showWarning(tr.exporting_couldnt_save_file(val=str(e))) - else: - os.unlink(file) - - # progress handler: old apkg exporter - def exported_media_count(cnt: int) -> None: - self.mw.taskman.run_on_main( - lambda: self.mw.progress.update( - label=tr.exporting_exported_media_file(count=cnt) - ) - ) - - # progress handler: adaptor for new colpkg importer into old exporting screen. - # don't rename this; there's a hack in pylib/exporting.py that assumes this - # name - def exported_media(progress: str) -> None: - self.mw.taskman.run_on_main( - lambda: self.mw.progress.update(label=progress) - ) - - def do_export() -> None: - self.exporter.exportInto(file) - - def on_done(future: Future) -> None: - self.mw.progress.finish() - hooks.media_files_did_export.remove(exported_media_count) - hooks.legacy_export_progress.remove(exported_media) - try: - # raises if exporter failed - future.result() - except Exception as exc: - show_exception(parent=self.mw, exception=exc) - self.on_export_failed() - else: - self.on_export_finished() - - gui_hooks.legacy_exporter_will_export(self.exporter) - if self.isVerbatim: - gui_hooks.collection_will_temporarily_close(self.mw.col) - self.mw.progress.start() - hooks.media_files_did_export.append(exported_media_count) - hooks.legacy_export_progress.append(exported_media) - - self.mw.taskman.run_in_background(do_export, on_done) - - def on_export_finished(self) -> None: - if self.isVerbatim: - msg = tr.exporting_collection_exported() - self.mw.reopen() - else: - if self.isTextNote: - msg = tr.exporting_note_exported(count=self.exporter.count) - else: - msg = tr.exporting_card_exported(count=self.exporter.count) - gui_hooks.legacy_exporter_did_export(self.exporter) - tooltip(msg, period=3000) - QDialog.reject(self) - - def on_export_failed(self) -> None: - if self.isVerbatim: - self.mw.reopen() - QDialog.reject(self) diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py index bcdf0d641cc..f06d7a47aad 100644 --- a/qt/aqt/import_export/exporting.py +++ b/qt/aqt/import_export/exporting.py @@ -4,15 +4,36 @@ from __future__ import annotations import os -from typing import Optional, Sequence +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.decks import DeckId +from anki.collection import ( + DeckIdLimit, + ExportAnkiPackageOptions, + ExportLimit, + NoteIdsLimit, + Progress, +) +from anki.decks import DeckId, DeckNameId from anki.notes import NoteId -from aqt import utils, webview +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, getSaveFile, showWarning, tr +from aqt.utils import ( + checkInvalidFilename, + disable_help_button, + getSaveFile, + showWarning, + tooltip, + tr, +) class ExportDialog(QDialog): @@ -23,54 +44,311 @@ 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 - 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()) + disable_help_button(self) + self.setup(did) self.open() - def reject(self) -> None: - assert self.web - self.col.set_wants_abort() - self.web.cleanup() - self.web = None + 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) -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, + 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, ) - 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 + + 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 diff --git a/qt/aqt/import_export/exporting_web.py b/qt/aqt/import_export/exporting_web.py new file mode 100644 index 00000000000..bcdf0d641cc --- /dev/null +++ b/qt/aqt/import_export/exporting_web.py @@ -0,0 +1,76 @@ +# 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 +from typing import Optional, Sequence + +import aqt.forms +import aqt.main +from anki.decks import DeckId +from anki.notes import NoteId +from aqt import utils, webview +from aqt.qt import * +from aqt.utils import checkInvalidFilename, getSaveFile, showWarning, tr + + +class ExportDialog(QDialog): + def __init__( + self, + mw: aqt.main.AnkiQt, + did: DeckId | None = None, + 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.nids = nids + 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 reject(self) -> None: + assert self.web + self.col.set_wants_abort() + self.web.cleanup() + self.web = None + QDialog.reject(self) + + +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, + ) + 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/main.py b/qt/aqt/main.py index 61f2363fc06..22975c01d45 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -50,7 +50,7 @@ from aqt.debug_console import show_debug_console from aqt.emptycards import show_empty_cards from aqt.flags import FlagManager -from aqt.import_export.exporting import ExportDialog +from aqt.import_export import exporting, exporting_web from aqt.import_export.importing import ( import_collection_package_op, import_file, @@ -1308,12 +1308,10 @@ def onImport(self) -> None: aqt.importing.onImport(self) def onExport(self, did: DeckId | None = None) -> None: - import aqt.exporting - if not self.pm.legacy_import_export(): - ExportDialog(self, did=did) + exporting_web.ExportDialog(self, did=did) else: - aqt.exporting.ExportDialog(self, did=did) + exporting.ExportDialog(self, did=did) # Installing add-ons from CLI / mimetype handler ########################################################################## diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 00a74d63b39..ad32cee5ebe 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -43,7 +43,7 @@ from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog -from aqt.import_export import exporting +from aqt.import_export import exporting_web 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 @@ -591,9 +591,7 @@ def get_notes_to_export() -> bytes: note_ids: Sequence[int] = [] if window := aqt.mw.app.activeWindow(): - from aqt.import_export.exporting import ExportDialog - - if isinstance(window, ExportDialog) and window.nids: + if isinstance(window, exporting_web.ExportDialog) and window.nids: note_ids = window.nids return NoteIds(note_ids=note_ids).SerializeToString() @@ -607,7 +605,9 @@ def get_export_file_path() -> bytes: def get_out_path() -> None: nonlocal path - path = exporting.get_out_path(req.exporter, req.extension, req.filename) or "" + path = ( + exporting_web.get_out_path(req.exporter, req.extension, req.filename) or "" + ) aqt.mw.taskman.run_on_main(get_out_path) while path is None: diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index f9b8e332bdb..72b89081cfe 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -901,14 +901,41 @@ def on_top_toolbar_did_init_links(links, toolbar): # Importing/exporting data ################### Hook( - name="legacy_exporter_will_export", - args=["legacy_exporter: anki.exporting.Exporter"], - doc="""Called before collection and deck exports performed by legacy exporters.""", + 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="legacy_exporter_did_export", - args=["legacy_exporter: anki.exporting.Exporter"], - doc="""Called after collection and deck exports performed by legacy exporters.""", + 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="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 ###################