mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

* Add crate csv
* Add start of csv importing on backend
* Add Menomosyne serializer
* Add csv and json importing on backend
* Add plaintext importing on frontend
* Add csv metadata extraction on backend
* Add csv importing with GUI
* Fix missing dfa file in build
Added compile_data_attr, then re-ran cargo/update.py.
* Don't use doubly buffered reader in csv
* Escape HTML entities if CSV is not HTML
Also use name 'is_html' consistently.
* Use decimal number as foreign ease (like '2.5')
* ForeignCard.ivl → ForeignCard.interval
* Only allow fixed set of CSV delimiters
* Map timestamp of ForeignCard to native due time
* Don't trim CSV records
* Document use of empty strings for defaults
* Avoid creating CardGenContexts for every note
This requires CardGenContext to be generic, so it works both with an
owned and borrowed notetype.
* Show all accepted file types in import file picker
* Add import_json_file()
* factor → ease_factor
* delimter_from_value → delimiter_from_value
* Map columns to fields, not the other way around
* Fallback to current config for csv metadata
* Add start of new import csv screen
* Temporary fix for compilation issue on Linux/Mac
* Disable jest bazel action for import-csv
Jest fails with an error code if no tests are available, but this would
not be noticable on Windows as Jest is not run there.
* Fix field mapping issue
* Revert "Temporary fix for compilation issue on Linux/Mac"
This reverts commit 21f8a26140
.
* Add HtmlSwitch and move Switch to components
* Fix spacing and make selectors consistent
* Fix shortcut tooltip
* Place import button at the top with path
* Fix meta column indices
* Remove NotetypeForString
* Fix queue and type of foreign cards
* Support different dupe resolution strategies
* Allow dupe resolution selection when importing CSV
* Test import of unnormalized text
Close #1863.
* Fix logging of foreign notes
* Implement CSV exports
* Use db_scalar() in notes_table_len()
* Rework CSV metadata
- Notetypes and decks are either defined by a global id or by a column.
- If a notetype id is provided, its field map must also be specified.
- If a notetype column is provided, fields are now mapped by index
instead of name at import time. So the first non-meta column is used for
the first field of every note, regardless of notetype. This makes
importing easier and should improve compatiblity with files without a
notetype column.
- Ensure first field can be mapped to a column.
- Meta columns must be defined as `#[meta name]:[column index]` instead
of in the `#columns` tag.
- Column labels contain the raw names defined by the file and must be
prettified by the frontend.
* Adjust frontend to new backend column mapping
* Add force flags for is_html and delimiter
* Detect if CSV is HTML by field content
* Update dupe resolution labels
* Simplify selectors
* Fix coalescence of oneofs in TS
* Disable meta columns from selection
Plus a lot of refactoring.
* Make import button stick to the bottom
* Write delimiter and html flag into csv
* Refetch field map after notetype change
* Fix log labels for csv import
* Log notes whose deck/notetype was missing
* Fix hiding of empty log queues
* Implement adding tags to all notes of a csv
* Fix dupe resolution not being set in log
* Implement adding tags to updated notes of a csv
* Check first note field is not empty
* Temporary fix for build on Linux/Mac
* Fix inverted html check (dae)
* Remove unused ftl string
* Delimiter → Separator
* Remove commented-out line
* Don't accept .json files
* Tweak tag ftl strings
* Remove redundant blur call
* Strip sound and add spaces in csv export
* Export HTML by default
* Fix unset deck in Mnemosyne import
Also accept both numbers and strings for notetypes and decks in JSON.
* Make DupeResolution::Update the default
* Fix missing dot in extension
* Make column indices 1-based
* Remove StickContainer from TagEditor
Fixes line breaking, border and z index on ImportCsvPage.
* Assign different key combos to tag editors
* Log all updated duplicates
Add a log field for the true number of found notes.
* Show identical notes as skipped
* Split tag-editor into separate ts module (dae)
* Add progress for CSV export
* Add progress for text import
* Tidy-ups after tag-editor split (dae)
- import-csv no longer depends on editor
- remove some commented lines
308 lines
9.8 KiB
Python
308 lines
9.8 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 abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import Sequence, Type
|
|
|
|
import aqt.forms
|
|
import aqt.main
|
|
from anki.collection import DeckIdLimit, ExportLimit, NoteIdsLimit, Progress
|
|
from anki.decks import DeckId, DeckNameId
|
|
from anki.notes import NoteId
|
|
from aqt import gui_hooks
|
|
from aqt.errors import show_exception
|
|
from aqt.operations import QueryOp
|
|
from aqt.progress import ProgressUpdate
|
|
from aqt.qt import *
|
|
from aqt.utils import (
|
|
checkInvalidFilename,
|
|
disable_help_button,
|
|
getSaveFile,
|
|
showWarning,
|
|
tooltip,
|
|
tr,
|
|
)
|
|
|
|
|
|
class ExportDialog(QDialog):
|
|
def __init__(
|
|
self,
|
|
mw: aqt.main.AnkiQt,
|
|
did: DeckId | None = None,
|
|
nids: Sequence[NoteId] | 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.exporter: Type[Exporter] = None
|
|
self.nids = nids
|
|
disable_help_button(self)
|
|
self.setup(did)
|
|
self.open()
|
|
|
|
def setup(self, did: DeckId | None) -> None:
|
|
self.exporters: list[Type[Exporter]] = [
|
|
ApkgExporter,
|
|
ColpkgExporter,
|
|
NoteCsvExporter,
|
|
CardCsvExporter,
|
|
]
|
|
self.frm.format.insertItems(
|
|
0, [f"{e.name()} (.{e.extension})" for e in self.exporters]
|
|
)
|
|
qconnect(self.frm.format.activated, self.exporter_changed)
|
|
if self.nids is None and not did:
|
|
# file>export defaults to colpkg
|
|
default_exporter_idx = 1
|
|
else:
|
|
default_exporter_idx = 0
|
|
self.frm.format.setCurrentIndex(default_exporter_idx)
|
|
self.exporter_changed(default_exporter_idx)
|
|
# deck list
|
|
if self.nids is None:
|
|
self.all_decks = self.col.decks.all_names_and_ids()
|
|
decks = [tr.exporting_all_decks()]
|
|
decks.extend(d.name for d in self.all_decks)
|
|
else:
|
|
decks = [tr.exporting_selected_notes()]
|
|
self.frm.deck.addItems(decks)
|
|
# save button
|
|
b = QPushButton(tr.exporting_export())
|
|
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
|
|
self.frm.includeHTML.setChecked(True)
|
|
# set default option if accessed through deck button
|
|
if did:
|
|
name = self.mw.col.decks.get(did)["name"]
|
|
index = self.frm.deck.findText(name)
|
|
self.frm.deck.setCurrentIndex(index)
|
|
self.frm.includeSched.setChecked(False)
|
|
|
|
def exporter_changed(self, idx: int) -> None:
|
|
self.exporter = self.exporters[idx]
|
|
self.frm.includeSched.setVisible(self.exporter.show_include_scheduling)
|
|
self.frm.includeMedia.setVisible(self.exporter.show_include_media)
|
|
self.frm.includeTags.setVisible(self.exporter.show_include_tags)
|
|
self.frm.includeHTML.setVisible(self.exporter.show_include_html)
|
|
self.frm.legacy_support.setVisible(self.exporter.show_legacy_support)
|
|
self.frm.deck.setVisible(self.exporter.show_deck_list)
|
|
|
|
def accept(self) -> None:
|
|
if not (out_path := self.get_out_path()):
|
|
return
|
|
self.exporter.export(self.mw, self.options(out_path))
|
|
QDialog.reject(self)
|
|
|
|
def get_out_path(self) -> str | None:
|
|
filename = self.filename()
|
|
while True:
|
|
path = getSaveFile(
|
|
parent=self,
|
|
title=tr.actions_export(),
|
|
dir_description="export",
|
|
key=self.exporter.name(),
|
|
ext="." + self.exporter.extension,
|
|
fname=filename,
|
|
)
|
|
if not path:
|
|
return None
|
|
if checkInvalidFilename(os.path.basename(path), dirsep=False):
|
|
continue
|
|
path = os.path.normpath(path)
|
|
if os.path.commonprefix([self.mw.pm.base, path]) == self.mw.pm.base:
|
|
showWarning("Please choose a different export location.")
|
|
continue
|
|
break
|
|
return path
|
|
|
|
def options(self, out_path: str) -> Options:
|
|
limit: ExportLimit = None
|
|
if self.nids:
|
|
limit = NoteIdsLimit(self.nids)
|
|
elif current_deck_id := self.current_deck_id():
|
|
limit = DeckIdLimit(current_deck_id)
|
|
|
|
return Options(
|
|
out_path=out_path,
|
|
include_scheduling=self.frm.includeSched.isChecked(),
|
|
include_media=self.frm.includeMedia.isChecked(),
|
|
include_tags=self.frm.includeTags.isChecked(),
|
|
include_html=self.frm.includeHTML.isChecked(),
|
|
legacy_support=self.frm.legacy_support.isChecked(),
|
|
limit=limit,
|
|
)
|
|
|
|
def current_deck_id(self) -> DeckId | None:
|
|
return (deck := self.current_deck()) and DeckId(deck.id) or None
|
|
|
|
def current_deck(self) -> DeckNameId | None:
|
|
if self.exporter.show_deck_list:
|
|
if idx := self.frm.deck.currentIndex():
|
|
return self.all_decks[idx - 1]
|
|
return None
|
|
|
|
def filename(self) -> str:
|
|
if self.exporter.show_deck_list:
|
|
deck_name = self.frm.deck.currentText()
|
|
stem = re.sub('[\\\\/?<>:*|"^]', "_", deck_name)
|
|
else:
|
|
time_str = time.strftime("%Y-%m-%d@%H-%M-%S", time.localtime(time.time()))
|
|
stem = f"{tr.exporting_collection()}-{time_str}"
|
|
return f"{stem}.{self.exporter.extension}"
|
|
|
|
|
|
@dataclass
|
|
class Options:
|
|
out_path: str
|
|
include_scheduling: bool
|
|
include_media: bool
|
|
include_tags: bool
|
|
include_html: bool
|
|
legacy_support: bool
|
|
limit: ExportLimit
|
|
|
|
|
|
class Exporter(ABC):
|
|
extension: str
|
|
show_deck_list = False
|
|
show_include_scheduling = False
|
|
show_include_media = False
|
|
show_include_tags = False
|
|
show_include_html = False
|
|
show_legacy_support = False
|
|
|
|
@staticmethod
|
|
@abstractmethod
|
|
def export(mw: aqt.main.AnkiQt, options: Options) -> None:
|
|
pass
|
|
|
|
@staticmethod
|
|
@abstractmethod
|
|
def name() -> str:
|
|
pass
|
|
|
|
|
|
class ColpkgExporter(Exporter):
|
|
extension = "colpkg"
|
|
show_include_media = True
|
|
show_legacy_support = True
|
|
|
|
@staticmethod
|
|
def name() -> str:
|
|
return tr.exporting_anki_collection_package()
|
|
|
|
@staticmethod
|
|
def export(mw: aqt.main.AnkiQt, options: Options) -> None:
|
|
def on_success(_: None) -> None:
|
|
mw.reopen()
|
|
tooltip(tr.exporting_collection_exported(), parent=mw)
|
|
|
|
def on_failure(exception: Exception) -> None:
|
|
mw.reopen()
|
|
show_exception(parent=mw, exception=exception)
|
|
|
|
gui_hooks.collection_will_temporarily_close(mw.col)
|
|
QueryOp(
|
|
parent=mw,
|
|
op=lambda col: col.export_collection_package(
|
|
options.out_path,
|
|
include_media=options.include_media,
|
|
legacy=options.legacy_support,
|
|
),
|
|
success=on_success,
|
|
).with_backend_progress(export_progress_update).failure(
|
|
on_failure
|
|
).run_in_background()
|
|
|
|
|
|
class ApkgExporter(Exporter):
|
|
extension = "apkg"
|
|
show_deck_list = True
|
|
show_include_scheduling = True
|
|
show_include_media = True
|
|
show_legacy_support = True
|
|
|
|
@staticmethod
|
|
def name() -> str:
|
|
return tr.exporting_anki_deck_package()
|
|
|
|
@staticmethod
|
|
def export(mw: aqt.main.AnkiQt, options: Options) -> None:
|
|
QueryOp(
|
|
parent=mw,
|
|
op=lambda col: col.export_anki_package(
|
|
out_path=options.out_path,
|
|
limit=options.limit,
|
|
with_scheduling=options.include_scheduling,
|
|
with_media=options.include_media,
|
|
legacy_support=options.legacy_support,
|
|
),
|
|
success=lambda count: tooltip(
|
|
tr.exporting_note_exported(count=count), parent=mw
|
|
),
|
|
).with_backend_progress(export_progress_update).run_in_background()
|
|
|
|
|
|
class NoteCsvExporter(Exporter):
|
|
extension = "txt"
|
|
show_deck_list = True
|
|
show_include_html = True
|
|
show_include_tags = True
|
|
|
|
@staticmethod
|
|
def name() -> str:
|
|
return tr.exporting_notes_in_plain_text()
|
|
|
|
@staticmethod
|
|
def export(mw: aqt.main.AnkiQt, options: Options) -> None:
|
|
QueryOp(
|
|
parent=mw,
|
|
op=lambda col: col.export_note_csv(
|
|
out_path=options.out_path,
|
|
limit=options.limit,
|
|
with_html=options.include_html,
|
|
with_tags=options.include_tags,
|
|
),
|
|
success=lambda count: tooltip(
|
|
tr.exporting_note_exported(count=count), parent=mw
|
|
),
|
|
).with_backend_progress(export_progress_update).run_in_background()
|
|
|
|
|
|
class CardCsvExporter(Exporter):
|
|
extension = "txt"
|
|
show_deck_list = True
|
|
show_include_html = True
|
|
|
|
@staticmethod
|
|
def name() -> str:
|
|
return tr.exporting_cards_in_plain_text()
|
|
|
|
@staticmethod
|
|
def export(mw: aqt.main.AnkiQt, options: Options) -> None:
|
|
QueryOp(
|
|
parent=mw,
|
|
op=lambda col: col.export_card_csv(
|
|
out_path=options.out_path,
|
|
limit=options.limit,
|
|
with_html=options.include_html,
|
|
),
|
|
success=lambda count: tooltip(
|
|
tr.exporting_card_exported(count=count), parent=mw
|
|
),
|
|
).with_backend_progress(export_progress_update).run_in_background()
|
|
|
|
|
|
def export_progress_update(progress: Progress, update: ProgressUpdate) -> None:
|
|
if not progress.HasField("exporting"):
|
|
return
|
|
update.label = progress.exporting
|
|
if update.user_wants_abort:
|
|
update.abort = True
|