diff --git a/ftl/core/exporting.ftl b/ftl/core/exporting.ftl
index be612f7daa9..277d24c6d2b 100644
--- a/ftl/core/exporting.ftl
+++ b/ftl/core/exporting.ftl
@@ -11,11 +11,24 @@ exporting-export = Export...
exporting-export-format = Export format:
exporting-include = Include:
exporting-include-html-and-media-references = Include HTML and media references
+exporting-include-html-and-media-references-help =
+ If enabled, markup and media information will be kept. Suitable if the file is intended to be reimported by Anki
+ or another app that can render HTML.
exporting-include-media = Include media
+exporting-include-media-help = If enabled, referenced media files will be bundled.
exporting-include-scheduling-information = Include scheduling information
+exporting-include-scheduling-information-help =
+ If enabled, study data like your review history and card intervals will be exported.
+ Unsuitable for sharing decks with others.
exporting-include-deck-configs = Include deck presets
+exporting-include-deck-configs-help =
+ If enabled, your deck option prests will be exported. However, the default preset is *never* shared.
exporting-include-tags = Include tags
+exporting-include-tags-help = If enabled, an additional column with note tags will be included.
exporting-support-older-anki-versions = Support older Anki versions (slower/larger files)
+exporting-support-older-anki-versions-help =
+ If enabled, the resulting file may also be imported by some outdated Anki clients, but it will
+ be larger, and importing and exporting will take longer.
exporting-notes-in-plain-text = Notes in Plain Text
exporting-selected-notes = Selected Notes
exporting-card-exported =
@@ -40,5 +53,23 @@ exporting-processed-media-files =
*[other] Processed { $count } media files...
}
exporting-include-deck = Include deck name
+exporting-include-deck-help =
+ If enabled, an additional column with deck names will be included. This will allow
+ the importer to sort cards into the intended decks.
exporting-include-notetype = Include notetype name
+exporting-include-notetype-help =
+ If enabled, an additional column with notetype names will be included. This will allow
+ the importer to assign notes the intended notetypes.
exporting-include-guid = Include unique identifier
+exporting-include-guid-help =
+ If enabled, an additional column with unique notetype identifiers will be included.
+ This allows to identify and update the exact orignal notes when the file is later reimported.
+exporting-format = Format
+exporting-content = Content
+exporting-format-help =
+ Anki supports multiple file formats for differing use cases:
+
+ - `{ exporting-anki-collection-package }`: Contains your entire collection. Useful for back-ups or moving between devices.
+ - `{ exporting-anki-deck-package }`: Lets you control exactly which notes and what data to include. Ideal for sharing decks with other users.
+ - `{ exporting-notes-in-plain-text }`: Converts notes into the universal CSV format, readable by many third-party tools like text editors or spreadsheet apps.
+ - `{ exporting-cards-in-plain-text }`: Converts the rendered front and back sides of cards into CSV format.
diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto
index 7c69429ab69..fc4c17f61a2 100644
--- a/proto/anki/frontend.proto
+++ b/proto/anki/frontend.proto
@@ -10,6 +10,8 @@ package anki.frontend;
import "anki/scheduler.proto";
import "anki/generic.proto";
import "anki/search.proto";
+import "anki/notes.proto";
+import "anki/import_export.proto";
service FrontendService {
// Returns values from the reviewer
@@ -22,6 +24,18 @@ service FrontendService {
rpc ImportDone(generic.Empty) returns (generic.Empty);
rpc SearchInBrowser(search.SearchNode) returns (generic.Empty);
+
+ // Get an export location chosen by the user
+ rpc GetExportFilePath(ExportFilePathRequest) returns (generic.String);
+
+ // Retrieve note ids to include in export file
+ rpc GetNotesToExport(generic.Empty) returns (notes.NoteIds);
+
+ // Show a tooltip in the main window
+ rpc ShowTooltip(generic.String) returns (generic.Empty);
+
+ rpc TemporarilyCloseAndExportCollectionPackage(
+ import_export.ExportCollectionPackageRequest) returns (generic.Empty);
}
service BackendFrontendService {}
@@ -35,3 +49,9 @@ message SetSchedulingStatesRequest {
string key = 1;
scheduler.SchedulingStates states = 2;
}
+
+message ExportFilePathRequest {
+ string exporter = 1;
+ string extension = 2;
+ string filename = 3;
+}
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index a8d5d62b458..f4bf255e287 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -10,6 +10,7 @@
card_rendering_pb2,
collection_pb2,
config_pb2,
+ frontend_pb2,
generic_pb2,
image_occlusion_pb2,
import_export_pb2,
@@ -57,6 +58,10 @@
MediaSyncStatus = sync_pb2.MediaSyncStatusResponse
FsrsItem = scheduler_pb2.FsrsItem
FsrsReview = scheduler_pb2.FsrsReview
+NoteIds = notes_pb2.NoteIds
+String = generic_pb2.String
+ExportFilePathRequest = frontend_pb2.ExportFilePathRequest
+ExportCollectionPackageRequest = import_export_pb2.ExportCollectionPackageRequest
import os
import sys
@@ -352,9 +357,12 @@ def export_collection_package(
self, out_path: str, include_media: bool, legacy: bool
) -> None:
self.close_for_full_sync()
- self._backend.export_collection_package(
- out_path=out_path, include_media=include_media, legacy=legacy
- )
+ try:
+ self._backend.export_collection_package(
+ out_path=out_path, include_media=include_media, legacy=legacy
+ )
+ finally:
+ self.reopen()
def import_anki_package(
self, request: ImportAnkiPackageRequest
diff --git a/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..2723b4c5987 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:
+ nids = self.selected_notes()
if not self.mw.pm.legacy_import_export():
- nids = self.selected_notes()
- ExportDialog(self.mw, nids=nids, parent=self)
+ 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_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 e3113b3b9ad..3c22e36710b 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,
@@ -1305,12 +1305,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 f7c7c50e9df..7f45aec7b74 100644
--- a/qt/aqt/mediasrv.py
+++ b/qt/aqt/mediasrv.py
@@ -10,11 +10,12 @@
import re
import sys
import threading
+import time
import traceback
from dataclasses import dataclass
from errno import EPROTOTYPE
from http import HTTPStatus
-from typing import Callable
+from typing import Callable, Sequence
import flask
import flask_cors
@@ -27,17 +28,27 @@
import aqt.main
import aqt.operations
from anki import hooks
-from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
+from anki.collection import (
+ ExportCollectionPackageRequest,
+ ExportFilePathRequest,
+ NoteIds,
+ OpChanges,
+ OpChangesOnly,
+ Progress,
+ SearchNode,
+ String,
+)
from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
+from aqt.import_export import exporting_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
from aqt.qt import *
-from aqt.utils import aqt_data_path, show_warning, tr
+from aqt.utils import aqt_data_path, show_warning, tooltip, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore
@@ -335,6 +346,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
+ "export-page",
]
@@ -578,6 +590,56 @@ def handle_on_main() -> None:
return b""
+def get_notes_to_export() -> bytes:
+ assert aqt.mw
+ note_ids: Sequence[int] = []
+
+ if window := aqt.mw.app.activeWindow():
+ if isinstance(window, exporting_web.ExportDialog) and window.nids:
+ note_ids = window.nids
+
+ return NoteIds(note_ids=note_ids).SerializeToString()
+
+
+def get_export_file_path() -> bytes:
+ assert aqt.mw
+ req = ExportFilePathRequest()
+ req.ParseFromString(request.data)
+ path = None
+
+ def get_out_path() -> None:
+ nonlocal path
+ path = (
+ exporting_web.get_out_path(req.exporter, req.extension, req.filename) or ""
+ )
+
+ aqt.mw.taskman.run_on_main(get_out_path)
+ while path is None:
+ time.sleep(0.05)
+ return String(val=path).SerializeToString()
+
+
+def show_tooltip() -> bytes:
+ assert aqt.mw
+ req = String()
+ req.ParseFromString(request.data)
+ aqt.mw.taskman.run_on_main(lambda: tooltip(req.val, parent=aqt.mw))
+ return b""
+
+
+def temporarily_close_and_export_collection_package() -> bytes:
+ assert aqt.mw
+ assert aqt.mw.col
+ req = ExportCollectionPackageRequest()
+ req.ParseFromString(request.data)
+ aqt.mw.col.export_collection_package(
+ req.out_path,
+ include_media=req.include_media,
+ legacy=req.legacy,
+ )
+ return b""
+
+
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
@@ -591,6 +653,10 @@ def handle_on_main() -> None:
import_json_file,
import_json_string,
search_in_browser,
+ get_notes_to_export,
+ get_export_file_path,
+ show_tooltip,
+ temporarily_close_and_export_collection_package,
]
@@ -604,6 +670,10 @@ def handle_on_main() -> None:
# ImportExportService
"get_csv_metadata",
"get_import_anki_package_presets",
+ "export_collection_package",
+ "export_anki_package",
+ "export_note_csv",
+ "export_card_csv",
# NotesService
"get_field_names",
"get_note",
diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py
index e4448084f5a..36745a138c9 100644
--- a/qt/aqt/utils.py
+++ b/qt/aqt/utils.py
@@ -676,7 +676,7 @@ def running_in_sandbox():
def getSaveFile(
- parent: QDialog,
+ parent: QWidget,
title: str,
dir_description: str,
key: str,
diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py
index 0956426d01d..1c3b8356dbf 100644
--- a/qt/aqt/webview.py
+++ b/qt/aqt/webview.py
@@ -258,6 +258,7 @@ class AnkiWebViewKind(Enum):
FIELDS = "fields"
IMPORT_LOG = "import log"
IMPORT_ANKI_PACKAGE = "anki package import"
+ EXPORT = "export"
class AnkiWebView(QWebEngineView):
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index 57c5443cf92..72b89081cfe 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -928,16 +928,6 @@ def on_exporter_will_export(export_options: ExportOptions, exporter: Exporter):
],
doc="""Called after collection and deck exports.""",
),
- Hook(
- name="legacy_exporter_will_export",
- args=["legacy_exporter: anki.exporting.Exporter"],
- doc="""Called before collection and deck exports performed by legacy exporters.""",
- ),
- Hook(
- name="legacy_exporter_did_export",
- args=["legacy_exporter: anki.exporting.Exporter"],
- doc="""Called after collection and deck exports performed by legacy exporters.""",
- ),
Hook(
name="exporters_list_did_initialize",
args=["exporters: list[Type[aqt.import_export.exporting.Exporter]]"],
diff --git a/ts/lib/components/BackendProgressIndicator.svelte b/ts/lib/components/BackendProgressIndicator.svelte
index 6a5778e21f3..246bcc1ecbc 100644
--- a/ts/lib/components/BackendProgressIndicator.svelte
+++ b/ts/lib/components/BackendProgressIndicator.svelte
@@ -3,15 +3,15 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
-{#if !result}
+{#if result === undefined}
diff --git a/ts/lib/tslib/help-page.ts b/ts/lib/tslib/help-page.ts
index e1aa921dfb0..611489fbcb2 100644
--- a/ts/lib/tslib/help-page.ts
+++ b/ts/lib/tslib/help-page.ts
@@ -44,5 +44,13 @@ export const HelpPage = {
root: "https://docs.ankiweb.net/importing/text-files.html",
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
html: "https://docs.ankiweb.net/importing/text-files.html#html",
+ notetypeColumn: "https://docs.ankiweb.net/importing/text-files.html#notetype-column",
+ deckColumn: "https://docs.ankiweb.net/importing/text-files.html#deck-column",
+ guidColumn: "https://docs.ankiweb.net/importing/text-files.html#guid-column",
+ },
+ Exporting: {
+ root: "https://docs.ankiweb.net/exporting.html",
+ packagedDecks: "https://docs.ankiweb.net/exporting.html#packaged-decks",
+ textFiles: "https://docs.ankiweb.net/exporting.html#text-files",
},
};
diff --git a/ts/lib/tslib/runtime-require-proxy.ts b/ts/lib/tslib/runtime-require-proxy.ts
new file mode 100644
index 00000000000..9ac87928bb0
--- /dev/null
+++ b/ts/lib/tslib/runtime-require-proxy.ts
@@ -0,0 +1,17 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+// Proxy for requireAsync in runtime-require.ts that is instantly available and
+// delegates to the original function once available.
+function requireAsyncProxy(name: string): Promise
> {
+ return new Promise((resolve) => {
+ const intervalId = setInterval(() => {
+ if (globalThis.requireAsync !== requireAsyncProxy) {
+ clearInterval(intervalId);
+ globalThis.requireAsync(name).then(resolve);
+ }
+ }, 50);
+ });
+}
+
+Object.assign(globalThis, { requireAsync: requireAsyncProxy });
diff --git a/ts/lib/tslib/runtime-require.ts b/ts/lib/tslib/runtime-require.ts
index 7ea815b5c01..3f8bf05eb0c 100644
--- a/ts/lib/tslib/runtime-require.ts
+++ b/ts/lib/tslib/runtime-require.ts
@@ -27,7 +27,8 @@ type AnkiPackages =
| "anki/location"
| "anki/surround"
| "anki/ui"
- | "anki/reviewer";
+ | "anki/reviewer"
+ | "anki/ExportPage";
type PackageDeprecation> = {
[key in keyof T]?: string;
};
@@ -77,6 +78,24 @@ function require(name: T): Record | und
}
}
+// Resolves as soon as the requested package is available.
+function requireAsync(name: T): Promise> {
+ return new Promise((resolve) => {
+ const runtimePackage = runtimePackages[name];
+ if (runtimePackage !== undefined) {
+ resolve(runtimePackage);
+ } else {
+ const intervalId = setInterval(() => {
+ const runtimePackage = runtimePackages[name];
+ if (runtimePackage !== undefined) {
+ clearInterval(intervalId);
+ resolve(runtimePackage);
+ }
+ }, 50);
+ }
+ });
+}
+
function listPackages(): string[] {
return Object.keys(runtimePackages);
}
@@ -91,8 +110,8 @@ function hasPackages(...names: string[]): boolean {
return true;
}
-// Export require() as a global.
-Object.assign(globalThis, { require });
+// Export require() and requireAsync() as globals.
+Object.assign(globalThis, { require, requireAsync });
registerPackage("anki/packages", {
// We also register require here, so add-ons can have a type-save variant of require (TODO, see AnkiPackages above)
diff --git a/ts/routes/export-page/+page.svelte b/ts/routes/export-page/+page.svelte
new file mode 100644
index 00000000000..98f7e1ce7a8
--- /dev/null
+++ b/ts/routes/export-page/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/ts/routes/export-page/+page.ts b/ts/routes/export-page/+page.ts
new file mode 100644
index 00000000000..7bce8361423
--- /dev/null
+++ b/ts/routes/export-page/+page.ts
@@ -0,0 +1,10 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+import { getDeckNames } from "@generated/backend";
+
+import type { PageLoad } from "./$types";
+
+export const load = (async (_) => {
+ const deckNames = await getDeckNames({ skipEmptyDefault: false, includeFiltered: true });
+ return { deckNames };
+}) satisfies PageLoad;
diff --git a/ts/routes/export-page/ContentOptions.svelte b/ts/routes/export-page/ContentOptions.svelte
new file mode 100644
index 00000000000..090b104d043
--- /dev/null
+++ b/ts/routes/export-page/ContentOptions.svelte
@@ -0,0 +1,259 @@
+
+
+
+
+ {
+ modal = e.detail.modal;
+ carousel = e.detail.carousel;
+ }}
+ />
+
+ {#if exporter.showDeckList}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeScheduling"))}
+ >
+ {tr.browsingNotes()}
+
+
+ {/if}
+
+ {#if exporter.showIncludeScheduling}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeScheduling"))}
+ >
+ {settings.includeScheduling.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeDeckConfigs}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeDeckConfigs"))}
+ >
+ {settings.includeDeckConfigs.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeMedia}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeMedia"))}
+ >
+ {settings.includeMedia.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeTags}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeTags"))}
+ >
+ {settings.includeTags.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeHtml}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeHtml"))}
+ >
+ {settings.includeHtml.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeDeck}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeDeck"))}
+ >
+ {settings.includeDeck.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeNotetype}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeNotetype"))}
+ >
+ {settings.includeNotetype.title}
+
+
+ {/if}
+
+ {#if exporter.showIncludeGuid}
+
+
+ openHelpModal(Object.keys(settings).indexOf("includeGuid"))}
+ >
+ {settings.includeGuid.title}
+
+
+ {/if}
+
diff --git a/ts/routes/export-page/ExportPage.svelte b/ts/routes/export-page/ExportPage.svelte
new file mode 100644
index 00000000000..d12e07a1953
--- /dev/null
+++ b/ts/routes/export-page/ExportPage.svelte
@@ -0,0 +1,120 @@
+
+
+
+{#if error}
+
+{:else if outPath}
+
+{:else}
+
+
+
+
+
+ {#if exporter}
+
+
+
+ {/if}
+
+{/if}
+
+
diff --git a/ts/routes/export-page/FileOptions.svelte b/ts/routes/export-page/FileOptions.svelte
new file mode 100644
index 00000000000..1543627eae2
--- /dev/null
+++ b/ts/routes/export-page/FileOptions.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+ {
+ modal = e.detail.modal;
+ carousel = e.detail.carousel;
+ }}
+ />
+
+ openHelpModal(Object.keys(settings).indexOf("format"))}
+ >
+ {settings.format.title}
+
+
+
+ {#if exporter.showLegacySupport}
+
+
+ openHelpModal(Object.keys(settings).indexOf("legacySupport"))}
+ >
+ {settings.legacySupport.title}
+
+
+ {/if}
+
diff --git a/ts/routes/export-page/StickyHeader.svelte b/ts/routes/export-page/StickyHeader.svelte
new file mode 100644
index 00000000000..a4ada758ba7
--- /dev/null
+++ b/ts/routes/export-page/StickyHeader.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+ {tr.actionsExport()}
+
+
+
+
+
+
+
diff --git a/ts/routes/export-page/deck/[deckId]/+page.svelte b/ts/routes/export-page/deck/[deckId]/+page.svelte
new file mode 100644
index 00000000000..8b9628a539a
--- /dev/null
+++ b/ts/routes/export-page/deck/[deckId]/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/ts/routes/export-page/deck/[deckId]/+page.ts b/ts/routes/export-page/deck/[deckId]/+page.ts
new file mode 100644
index 00000000000..2acfb4f07bd
--- /dev/null
+++ b/ts/routes/export-page/deck/[deckId]/+page.ts
@@ -0,0 +1,11 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+import { getDeckNames } from "@generated/backend";
+
+import type { PageLoad } from "./$types";
+
+export const load = (async ({ params }) => {
+ const deckNames = await getDeckNames({ skipEmptyDefault: false, includeFiltered: true });
+ console.log(BigInt(params.deckId));
+ return { deckId: BigInt(params.deckId), deckNames };
+}) satisfies PageLoad;
diff --git a/ts/routes/export-page/export-page-base.scss b/ts/routes/export-page/export-page-base.scss
new file mode 100644
index 00000000000..fe96efcac90
--- /dev/null
+++ b/ts/routes/export-page/export-page-base.scss
@@ -0,0 +1,18 @@
+@use "../lib/sass/bootstrap-dark";
+
+@import "../lib/sass/base";
+
+@import "../lib/sass/bootstrap-tooltip";
+@import "bootstrap/scss/buttons";
+
+.night-mode {
+ @include bootstrap-dark.night-mode;
+}
+
+body {
+ padding: 0 1em 1em 1em;
+}
+
+html {
+ height: initial;
+}
diff --git a/ts/routes/export-page/lib.ts b/ts/routes/export-page/lib.ts
new file mode 100644
index 00000000000..a2e3439fb10
--- /dev/null
+++ b/ts/routes/export-page/lib.ts
@@ -0,0 +1,137 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { Empty } from "@generated/anki/generic_pb";
+import { ExportAnkiPackageOptions, ExportLimit } from "@generated/anki/import_export_pb";
+import { NoteIds } from "@generated/anki/notes_pb";
+import {
+ exportAnkiPackage,
+ exportCardCsv,
+ exportNoteCsv,
+ showTooltip,
+ temporarilyCloseAndExportCollectionPackage,
+} from "@generated/backend";
+import * as tr from "@generated/ftl";
+
+import type { Exporter, ExportOptions } from "./types";
+
+export function getExporters(withLimit: boolean): Exporter[] {
+ return [
+ createExporter("colpkg", tr.exportingAnkiCollectionPackage(), {
+ showIncludeMedia: true,
+ showLegacySupport: true,
+ isDefault: !withLimit,
+ }, _exportCollectionPackage),
+ createExporter("apkg", tr.exportingAnkiDeckPackage(), {
+ showDeckList: true,
+ showIncludeScheduling: true,
+ showIncludeDeckConfigs: true,
+ showIncludeMedia: true,
+ showLegacySupport: true,
+ isDefault: withLimit,
+ }, _exportAnkiPackage),
+ createExporter("txt", tr.exportingNotesInPlainText(), {
+ showDeckList: true,
+ showIncludeTags: true,
+ showIncludeHtml: true,
+ showIncludeDeck: true,
+ showIncludeNotetype: true,
+ showIncludeGuid: true,
+ }, _exportNoteCsv),
+ createExporter("txt", tr.exportingCardsInPlainText(), {
+ showDeckList: true,
+ showIncludeHtml: true,
+ }, _exportCardCsv),
+ ];
+}
+
+export function createExportLimit(deckIdOrNoteIds: bigint | bigint[] | null): ExportLimit {
+ if (deckIdOrNoteIds === null) {
+ return new ExportLimit({
+ limit: {
+ case: "wholeCollection",
+ value: new Empty(),
+ },
+ });
+ }
+ if (Array.isArray(deckIdOrNoteIds)) {
+ return new ExportLimit({
+ limit: {
+ case: "noteIds",
+ value: new NoteIds({ noteIds: deckIdOrNoteIds }),
+ },
+ });
+ }
+ return new ExportLimit({
+ limit: { case: "deckId", value: deckIdOrNoteIds },
+ });
+}
+
+function createExporter(
+ extension: string,
+ label: string,
+ options: Partial,
+ doExport: (outPath: string, limit: ExportLimit, options: ExportOptions) => Promise,
+): Exporter {
+ return {
+ extension,
+ label,
+ showDeckList: options.showDeckList ?? false,
+ showIncludeScheduling: options.showIncludeScheduling ?? false,
+ showIncludeDeckConfigs: options.showIncludeDeckConfigs ?? false,
+ showIncludeMedia: options.showIncludeMedia ?? false,
+ showIncludeTags: options.showIncludeTags ?? false,
+ showIncludeHtml: options.showIncludeHtml ?? false,
+ showLegacySupport: options.showLegacySupport ?? false,
+ showIncludeDeck: options.showIncludeDeck ?? false,
+ showIncludeNotetype: options.showIncludeNotetype ?? false,
+ showIncludeGuid: options.showIncludeGuid ?? false,
+ isDefault: options.isDefault ?? false,
+ doExport,
+ };
+}
+
+async function _exportCollectionPackage(outPath: string, limit: ExportLimit, options: ExportOptions) {
+ await temporarilyCloseAndExportCollectionPackage({
+ outPath,
+ includeMedia: options.includeMedia,
+ legacy: options.legacySupport,
+ });
+ showTooltip({ val: tr.exportingCollectionExported() });
+}
+
+async function _exportAnkiPackage(outPath: string, limit: ExportLimit, options: ExportOptions) {
+ const result = await exportAnkiPackage({
+ outPath,
+ limit,
+ options: new ExportAnkiPackageOptions({
+ withScheduling: options.includeScheduling,
+ withDeckConfigs: options.includeDeckConfigs,
+ withMedia: options.includeMedia,
+ legacy: options.legacySupport,
+ }),
+ });
+ showTooltip({ val: tr.exportingNoteExported({ count: result.val }) });
+}
+
+async function _exportNoteCsv(outPath: string, limit: ExportLimit, options: ExportOptions) {
+ const result = await exportNoteCsv({
+ outPath,
+ limit,
+ withHtml: options.includeHtml,
+ withTags: options.includeTags,
+ withDeck: options.includeDeck,
+ withNotetype: options.includeNotetype,
+ withGuid: options.includeGuid,
+ });
+ showTooltip({ val: tr.exportingNoteExported({ count: result.val }) });
+}
+
+async function _exportCardCsv(outPath: string, limit: ExportLimit, options: ExportOptions) {
+ const result = await exportCardCsv({
+ outPath,
+ limit,
+ withHtml: options.includeHtml,
+ });
+ showTooltip({ val: tr.exportingCardExported({ count: result.val }) });
+}
diff --git a/ts/routes/export-page/notes/+page.svelte b/ts/routes/export-page/notes/+page.svelte
new file mode 100644
index 00000000000..345a3bdfd3c
--- /dev/null
+++ b/ts/routes/export-page/notes/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/ts/routes/export-page/notes/+page.ts b/ts/routes/export-page/notes/+page.ts
new file mode 100644
index 00000000000..c67ece77040
--- /dev/null
+++ b/ts/routes/export-page/notes/+page.ts
@@ -0,0 +1,10 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+import { getNotesToExport } from "@generated/backend";
+
+import type { PageLoad } from "./$types";
+
+export const load = (async (_) => {
+ const noteIds = await getNotesToExport({});
+ return { noteIds };
+}) satisfies PageLoad;
diff --git a/ts/routes/export-page/types.ts b/ts/routes/export-page/types.ts
new file mode 100644
index 00000000000..47b7e899344
--- /dev/null
+++ b/ts/routes/export-page/types.ts
@@ -0,0 +1,57 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import type { ExportLimit } from "@generated/anki/import_export_pb";
+
+export interface Exporter {
+ extension: string;
+ label: string;
+ showDeckList: boolean;
+ showIncludeScheduling: boolean;
+ showIncludeDeckConfigs: boolean;
+ showIncludeMedia: boolean;
+ showIncludeTags: boolean;
+ showIncludeHtml: boolean;
+ showLegacySupport: boolean;
+ showIncludeDeck: boolean;
+ showIncludeNotetype: boolean;
+ showIncludeGuid: boolean;
+ isDefault: boolean;
+ doExport: (outPath: string, limit: ExportLimit, options: ExportOptions) => Promise;
+}
+
+export interface Limit {
+ extension: string;
+ label: string;
+ showDeckList: boolean;
+ showIncludeScheduling: boolean;
+ showIncludeDeckConfigs: boolean;
+ showIncludeMedia: boolean;
+ showIncludeTags: boolean;
+ showIncludeHtml: boolean;
+ showLegacySupport: boolean;
+ showIncludeDeck: boolean;
+ showIncludeNotetype: boolean;
+ showIncludeGuid: boolean;
+ isDefault: boolean;
+}
+
+export type LimitValue = bigint | bigint[] | null;
+
+export interface LimitChoice {
+ label: string;
+ value: number;
+ limit: LimitValue;
+}
+
+export interface ExportOptions {
+ includeScheduling: boolean;
+ includeDeckConfigs: boolean;
+ includeMedia: boolean;
+ includeTags: boolean;
+ includeHtml: boolean;
+ legacySupport: boolean;
+ includeDeck: boolean;
+ includeNotetype: boolean;
+ includeGuid: boolean;
+}
diff --git a/ts/src/app.html b/ts/src/app.html
index 77a5ff52c92..628f2c135ae 100644
--- a/ts/src/app.html
+++ b/ts/src/app.html
@@ -1,12 +1,13 @@
-
+
-
-
-
-
- %sveltekit.head%
-
-
- %sveltekit.body%
-
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+