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%
+