anki/qt/aqt/exporting.py

227 lines
8.2 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# 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
2019-12-20 10:19:03 +01:00
import time
2020-03-06 05:19:25 +01:00
from concurrent.futures import Future
from typing import Optional
2019-12-20 10:19:03 +01:00
import aqt
import aqt.forms
import aqt.main
2020-01-15 04:49:26 +01:00
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
2019-12-20 10:19:03 +01:00
from aqt.qt import *
from aqt.utils import (
checkInvalidFilename,
disable_help_button,
getSaveFile,
showWarning,
tooltip,
tr,
)
2019-12-20 10:19:03 +01:00
class ExportDialog(QDialog):
2020-02-25 08:56:46 +01:00
def __init__(
self,
mw: aqt.main.AnkiQt,
did: DeckId | None = None,
cids: list[CardId] | None = None,
parent: Optional[QWidget] = None,
2020-02-25 08:56:46 +01:00
):
QDialog.__init__(self, parent or mw, Qt.WindowType.Window)
self.mw = mw
2020-03-06 05:03:23 +01:00
self.col = mw.col.weakref()
self.frm = aqt.forms.exporting.Ui_ExportDialog()
self.frm.setupUi(self)
Add apkg import/export on backend (#1743) * Add apkg export on backend * Filter out missing media-paths at write time * Make TagMatcher::new() infallible * Gather export data instead of copying directly * Revert changes to rslib/src/tags/ * Reuse filename_is_safe/check_filename_safe() * Accept func to produce MediaIter in export_apkg() * Only store file folder once in MediaIter * Use temporary tables for gathering export_apkg() now accepts a search instead of a deck id. Decks are gathered according to the matched notes' cards. * Use schedule_as_new() to reset cards * ExportData → ExchangeData * Ignore ascii case when filtering system tags * search_notes_cards_into_table → search_cards_of_notes_into_table * Start on apkg importing on backend * Fix due dates in days for apkg export * Refactor import-export/package - Move media and meta code into appropriate modules. - Normalize/check for normalization when deserializing media entries. * Add SafeMediaEntry for deserialized MediaEntries * Prepare media based on checksums - Ensure all existing media files are hashed. - Hash incoming files during preparation to detect conflicts. - Uniquify names of conflicting files with hash (not notetype id). - Mark media files as used while importing notes. - Finally copy used media. * Handle encoding in `replace_media_refs()` * Add trait to keep down cow boilerplate * Add notetypes immediately instaed of preparing * Move target_col into Context * Add notes immediately instaed of preparing * Note id, not guid of conflicting notes * Add import_decks() * decks_configs → deck_configs * Add import_deck_configs() * Add import_cards(), import_revlog() * Use dyn instead of generic for media_fn Otherwise, would have to pass None with type annotation in the default case. * Fix signature of import_apkg() * Fix search_cards_of_notes_into_table() * Test new functions in text.rs * Add roundtrip test for apkg (stub) * Keep source id of imported cards (or skip) * Keep source ids of imported revlog (or skip) * Try to keep source ids of imported notes * Make adding notetype with id undoable * Wrap apkg import in transaction * Keep source ids of imported deck configs (or skip) * Handle card due dates and original due/did * Fix importing cards/revlog Card ids are manually uniquified. * Factor out card importing * Refactor card and revlog importing * Factor out card importing Also handle missing parents . * Factor out note importing * Factor out media importing * Maybe upgrade scheduler of apkg * Fix parent deck gathering * Unconditionally import static media * Fix deck importing edge cases Test those edge cases, and add some global test helpers. * Test note importing * Let import_apkg() take a progress func * Expand roundtrip apkg test * Use fat pointer to avoid propogating generics * Fix progress_fn type * Expose apkg export/import on backend * Return note log when importing apkg * Fix archived collection name on apkg import * Add CollectionOpWithBackendProgress * Fix wrong Interrupted Exception being checked * Add ClosedCollectionOp * Add note ids to log and strip HTML * Update progress when checking incoming media too * Conditionally enable new importing in GUI * Fix all_checksums() for media import Entries of deleted files are nulled, not removed. * Make apkg exporting on backend abortable * Return number of notes imported from apkg * Fix exception printing for QueryOp as well * Add QueryOpWithBackendProgress Also support backend exporting progress. * Expose new apkg and colpkg exporting * Open transaction in insert_data() Was slowing down exporting by several orders of magnitude. * Handle zstd-compressed apkg * Add legacy arg to ExportAnkiPackage Currently not exposed on the frontend * Remove unused import in proto file * Add symlink for typechecking of import_export_pb2 * Avoid kwargs in pb message creation, so typechecking is not lost Protobuf's behaviour is rather subtle and I had to dig through the docs to figure it out: set a field on a submessage to automatically assign the submessage to the parent, or call SetInParent() to persist a default version of the field you specified. * Avoid re-exporting protobuf msgs we only use internally * Stop after one test failure mypy often fails much faster than pylint * Avoid an extra allocation when extracting media checksums * Update progress after prepare_media() finishes Otherwise the bulk of the import ends up being shown as "Checked: 0" in the progress window. * Show progress of note imports Note import is the slowest part, so showing progress here makes the UI feel more responsive. * Reset filtered decks at import time Before this change, filtered decks exported with scheduling remained filtered on import, and maybe_remove_from_filtered_deck() moved cards into them as their home deck, leading to errors during review. We may still want to provide a way to preserve filtered decks on import, but to do that we'll need to ensure we don't rewrite the home decks of cards, and we'll need to ensure the home decks are included as part of the import (or give an error if they're not). https://github.com/ankitects/anki/pull/1743/files#r839346423 * Fix a corner-case where due dates were shifted by a day This issue existed in the old Python code as well. We need to include the user's UTC offset in the exported file, or days_elapsed falls back on the v1 cutoff calculation, which may be a day earlier or later than the v2 calculation. * Log conflicting note in remapped nt case * take_fields() → into_fields() * Alias `[u8; 20]` with `Sha1Hash` * Truncate logged fields * Rework apkg note import tests - Use macros for more helpful errors. - Split monolith into unit tests. - Fix some unknown error with the previous test along the way. (Was failing after 969484de4388d225c9f17d94534b3ba0094c3568.) * Fix sorting of imported decks Also adjust the test, so it fails without the patch. It was only passing before, because the parent deck happened to come before the inconsistently capitalised child alphabetically. But we want all parent decks to be imported before their child decks, so their children can adopt their capitalisation. * target[_id]s → existing_card[_id]s * export_collection_extracting_media() → ... export_into_collection_file() * target_already_exists→card_ordinal_already_exists * Add search_cards_of_notes_into_table.sql * Imrove type of apkg export selector/limit * Remove redundant call to mod_schema() * Parent tooltips to mw * Fix a crash when truncating note text String::truncate() is a bit of a footgun, and I've hit this before too :-) * Remove ExportLimit in favour of separate classes * Remove OpWithBackendProgress and ClosedCollectionOp Backend progress logic is now in ProgressManager. QueryOp can be used for running on closed collection. Also fix aborting of colpkg exports, which slipped through in #1817. * Tidy up import log * Avoid QDialog.exec() * Default to excluding scheuling for deck list deck * Use IncrementalProgress in whole import_export code * Compare checksums when importing colpkgs * Avoid registering changes if hashes are not needed * ImportProgress::Collection → ImportProgress::File * Make downgrading apkgs depend on meta version * Generalise IncrementableProgress And use it in entire import_export code instead. * Fix type complexity lint * Take count_map for IncrementableProgress::get_inner * Replace import/export env with Shift click * Accept all args from update() for backend progress * Pass fields of ProgressUpdate explicitly * Move update_interval into IncrementableProgress * Outsource incrementing into Incrementor * Mutate ProgressUpdate in progress_update callback * Switch import/export legacy toggle to profile setting Shift would have been nice, but the existing shortcuts complicate things. If the user triggers an import with ctrl+shift+i, shift is unlikely to have been released by the time our code runs, meaning the user accidentally triggers the new code. We could potentially wait a while before bringing up the dialog, but then we're forced to guess at how long it will take the user to release the key. One alternative would be to use alt instead of shift, but then we need to trigger our shortcut when that key is pressed as well, and it could potentially cause a conflict with an add-on that already uses that combination. * Show extension in export dialog * Continue to provide separate options for schema 11+18 colpkg export * Default to colpkg export when using File>Export * Improve appearance of combo boxes when switching between apkg/colpkg + Deal with long deck names * Convert newlines to spaces when showing fields from import Ensures each imported note appears on a separate line * Don't separate total note count from the other summary lines This may come down to personal preference, but I feel the other counts are equally as important, and separating them feels like it makes it a bit easier to ignore them. * Fix 'deck not normal' error when importing a filtered deck for the 2nd time * Fix [Identical] being shown on first import * Revert "Continue to provide separate options for schema 11+18 colpkg export" This reverts commit 8f0b2c175f4794d642823b60414d142a12768441. Will use a different approach * Move legacy support into a separate exporter option; add to apkg export * Adjust 'too new' message to also apply to .apkg import case * Show a better message when attempting to import new apkg into old code Previously the user could end seeing a message like: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1: invalid start byte Unfortunately we can't retroactively fix this for older clients. * Hide legacy support option in older exporting screen * Reflect change from paths to fnames in type & name * Make imported decks normal at once Then skip special casing in update_deck(). Also skip updating description if new one is empty. Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-05-02 13:12:46 +02:00
self.frm.legacy_support.setVisible(False)
self.exporter: Exporter | None = None
2020-02-09 07:37:50 +01:00
self.cids = cids
disable_help_button(self)
2014-06-20 02:13:12 +02:00
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
2020-02-09 07:19:37 +01:00
if did or self.cids:
2019-12-23 01:34:10 +01:00
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:
2021-03-26 04:48:26 +01:00
self.decks = [tr.exporting_all_decks()]
self.decks.extend(d.name for d in self.col.decks.all_names_and_ids())
else:
2021-03-26 04:48:26 +01:00
self.decks = [tr.exporting_selected_notes()]
self.frm.deck.addItems(self.decks)
# save button
2021-03-26 04:48:26 +01:00
b = QPushButton(tr.exporting_export())
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
2014-06-20 02:13:12 +02:00
# set default option if accessed through deck button
if did:
2019-12-23 01:34:10 +01:00
name = self.mw.col.decks.get(did)["name"]
2014-06-20 02:13:12 +02:00
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(
2019-12-23 01:34:10 +01:00
getattr(self.exporter, "includeSched", None) is not None
)
self.frm.includeMedia.setVisible(
2019-12-23 01:34:10 +01:00
getattr(self.exporter, "includeMedia", None) is not None
)
self.frm.includeTags.setVisible(
2019-12-23 01:34:10 +01:00
getattr(self.exporter, "includeTags", None) is not None
)
2019-03-04 23:57:53 +01:00
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)
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 02:28:01 +02:00
# 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:
2019-12-23 01:34:10 +01:00
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:
2019-12-23 01:34:10 +01:00
name = time.strftime("-%Y-%m-%d@%H-%M-%S", time.localtime(time.time()))
2021-03-26 04:48:26 +01:00
deck_name = tr.exporting_collection() + name
else:
# Get deck name and remove invalid filename characters
deck_name = self.decks[self.frm.deck.currentIndex()]
2019-12-23 01:34:10 +01:00
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:
2019-12-23 01:34:10 +01:00
file = getSaveFile(
2020-08-31 05:29:28 +02:00
self,
2021-03-26 04:48:26 +01:00
tr.actions_export(),
2020-08-31 05:29:28 +02:00
"export",
key_str,
self.exporter.ext,
fname=filename,
2019-12-23 01:34:10 +01:00
)
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:
2020-03-06 05:19:25 +01:00
# 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)
2020-03-06 05:19:25 +01:00
# progress handler: old apkg exporter
def exported_media_count(cnt: int) -> None:
2020-03-06 05:19:25 +01:00
self.mw.taskman.run_on_main(
lambda: self.mw.progress.update(
label=tr.exporting_exported_media_file(count=cnt)
2019-12-23 01:34:10 +01:00
)
)
2020-03-06 05:19:25 +01:00
# 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)
)
2021-02-01 14:28:21 +01:00
def do_export() -> None:
self.exporter.exportInto(file)
2020-03-06 05:19:25 +01:00
2021-02-01 14:28:21 +01:00
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()
2020-03-06 05:19:25 +01:00
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)
2020-03-06 05:19:25 +01:00
self.mw.taskman.run_in_background(do_export, on_done)
def on_export_finished(self) -> None:
2020-03-06 05:19:25 +01:00
if self.isVerbatim:
2021-03-26 04:48:26 +01:00
msg = tr.exporting_collection_exported()
2020-03-06 05:19:25 +01:00
self.mw.reopen()
else:
if self.isTextNote:
msg = tr.exporting_note_exported(count=self.exporter.count)
2020-03-06 05:19:25 +01:00
else:
msg = tr.exporting_card_exported(count=self.exporter.count)
gui_hooks.legacy_exporter_did_export(self.exporter)
2020-03-06 05:19:25 +01:00
tooltip(msg, period=3000)
QDialog.reject(self)
def on_export_failed(self) -> None:
if self.isVerbatim:
self.mw.reopen()
QDialog.reject(self)