anki/qt/aqt/exporting.py
RumovZ 6da5e5b042
CSV import/export fixes and features (#1898)
* Fix footer moving upwards

* Fix column detection

Was broken because escaped line breaks were not considered.
Also removes delimiter detection on `#columns:` line. User must use tabs
or set delimiter beforehand.

* Add CSV preview

* Parse `#tags column:`

* Optionally export deck and notetype with CSV

* Avoid clones in CSV export

* Prevent bottom of page appearing under footer (dae)

* Increase padding to 1em (dae)

With 0.5em, when a vertical scrollbar is shown, it sits right next to
the right edge of the content, making it look like there's no right
margin.

* Experimental changes to make table fit+scroll (dae)

- limit individual cells to 15em, and show ellipses when truncated
- limit total table width to body width, so that inner table is shown
with scrollbar
- use class rather than id - ids are bad practice in Svelte components,
as more than one may be displayed on a single page

* Skip importing foreign notes with filtered decks

Were implicitly imported into the default deck before.
Also some refactoring to fetch deck ids and names beforehand.

* Hide spacer below hidden field mapping

* Fix guid being replaced when updating note

* Fix dupe identity check

Canonify tags before checking if dupe is identical, but only add update
tags later if appropriate.

* Fix deck export for notes with missing card 1

* Fix note lines starting with `#`

csv crate doesn't support escaping a leading comment char. :(

* Support import/export of guids

* Strip HTML from preview rows

* Fix initially set deck if current is filtered

* Make isHtml toggle reactive

* Fix `html_to_text_line()` stripping sound names

* Tweak export option labels

* Switch to patched rust-csv fork

Fixes writing lines starting with `#`, so revert 5ece10ad05f331.

* List column options with first column field

* Fix flag for exports with HTML stripped
2022-06-09 10:28:01 +10:00

223 lines
8.0 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import os
import re
import time
from concurrent.futures import Future
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,
):
QDialog.__init__(self, 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()
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)
tooltip(msg, period=3000)
QDialog.reject(self)
def on_export_failed(self) -> None:
if self.isVerbatim:
self.mw.reopen()
QDialog.reject(self)