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
###################