mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 09:16:38 -04:00
Add plaintext importing on frontend
This commit is contained in:
parent
9f0f4e6159
commit
db5f167de5
5 changed files with 321 additions and 11 deletions
|
@ -23,6 +23,7 @@ ignored-classes=
|
||||||
Cram,
|
Cram,
|
||||||
ScheduleCardsAsNewRequest,
|
ScheduleCardsAsNewRequest,
|
||||||
ExportAnkiPackageRequest,
|
ExportAnkiPackageRequest,
|
||||||
|
CsvColumn,
|
||||||
|
|
||||||
[REPORTS]
|
[REPORTS]
|
||||||
output-format=colorized
|
output-format=colorized
|
||||||
|
|
|
@ -33,7 +33,8 @@ OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo
|
||||||
BrowserRow = search_pb2.BrowserRow
|
BrowserRow = search_pb2.BrowserRow
|
||||||
BrowserColumns = search_pb2.BrowserColumns
|
BrowserColumns = search_pb2.BrowserColumns
|
||||||
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
|
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
|
||||||
ImportLogWithChanges = import_export_pb2.ImportAnkiPackageResponse
|
ImportLogWithChanges = import_export_pb2.ImportResponse
|
||||||
|
CsvColumn = import_export_pb2.ImportCsvRequest.CsvColumn
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import os
|
import os
|
||||||
|
@ -403,6 +404,27 @@ class Collection(DeprecatedNamesMixin):
|
||||||
request.whole_collection.SetInParent()
|
request.whole_collection.SetInParent()
|
||||||
return self._backend.export_anki_package(request)
|
return self._backend.export_anki_package(request)
|
||||||
|
|
||||||
|
def import_csv(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
deck_id: DeckId,
|
||||||
|
notetype_id: NotetypeId,
|
||||||
|
columns: list[CsvColumn],
|
||||||
|
delimiter: str,
|
||||||
|
allow_html: bool,
|
||||||
|
) -> ImportLogWithChanges:
|
||||||
|
return self._backend.import_csv(
|
||||||
|
path=path,
|
||||||
|
deck_id=deck_id,
|
||||||
|
notetype_id=notetype_id,
|
||||||
|
delimiter=delimiter,
|
||||||
|
columns=columns,
|
||||||
|
allow_html=allow_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def import_json(self, json: str) -> ImportLogWithChanges:
|
||||||
|
return self._backend.import_json(json)
|
||||||
|
|
||||||
# Object helpers
|
# Object helpers
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ ignored-classes=
|
||||||
CustomStudyRequest,
|
CustomStudyRequest,
|
||||||
Cram,
|
Cram,
|
||||||
ScheduleCardsAsNewRequest,
|
ScheduleCardsAsNewRequest,
|
||||||
|
CsvColumn,
|
||||||
|
|
||||||
[REPORTS]
|
[REPORTS]
|
||||||
output-format=colorized
|
output-format=colorized
|
||||||
|
|
265
qt/aqt/import_export/import_dialog.py
Normal file
265
qt/aqt/import_export/import_dialog.py
Normal file
|
@ -0,0 +1,265 @@
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
"""Stopgap / not production-ready."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import aqt.forms
|
||||||
|
import aqt.main
|
||||||
|
from anki.collection import CsvColumn
|
||||||
|
from aqt.import_export.importing import import_progress_update, show_import_log
|
||||||
|
from aqt.operations import CollectionOp
|
||||||
|
from aqt.qt import *
|
||||||
|
from aqt.utils import HelpPage, disable_help_button, getText, openHelp, showWarning, tr
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeMap(QDialog):
|
||||||
|
def __init__(self, mw: aqt.main.AnkiQt, model: dict, current: str) -> None:
|
||||||
|
QDialog.__init__(self, mw, Qt.WindowType.Window)
|
||||||
|
self.mw = mw
|
||||||
|
self.model = model
|
||||||
|
self.frm = aqt.forms.changemap.Ui_ChangeMap()
|
||||||
|
self.frm.setupUi(self)
|
||||||
|
disable_help_button(self)
|
||||||
|
n = 0
|
||||||
|
setCurrent = False
|
||||||
|
for field in self.model["flds"]:
|
||||||
|
item = QListWidgetItem(tr.importing_map_to(val=field["name"]))
|
||||||
|
self.frm.fields.addItem(item)
|
||||||
|
if current == field["name"]:
|
||||||
|
setCurrent = True
|
||||||
|
self.frm.fields.setCurrentRow(n)
|
||||||
|
n += 1
|
||||||
|
self.frm.fields.addItem(QListWidgetItem(tr.importing_map_to_tags()))
|
||||||
|
self.frm.fields.addItem(QListWidgetItem(tr.importing_ignore_field()))
|
||||||
|
if not setCurrent:
|
||||||
|
if current == "_tags":
|
||||||
|
self.frm.fields.setCurrentRow(n)
|
||||||
|
else:
|
||||||
|
self.frm.fields.setCurrentRow(n + 1)
|
||||||
|
self.field: Optional[str] = None
|
||||||
|
|
||||||
|
def getField(self) -> str:
|
||||||
|
self.exec()
|
||||||
|
return self.field
|
||||||
|
|
||||||
|
def accept(self) -> None:
|
||||||
|
row = self.frm.fields.currentRow()
|
||||||
|
if row < len(self.model["flds"]):
|
||||||
|
self.field = self.model["flds"][row]["name"]
|
||||||
|
elif row == self.frm.fields.count() - 2:
|
||||||
|
self.field = "_tags"
|
||||||
|
else:
|
||||||
|
self.field = None
|
||||||
|
QDialog.accept(self)
|
||||||
|
|
||||||
|
def reject(self) -> None:
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
|
# called by importFile() when importing a mappable file like .csv
|
||||||
|
# ImportType = Union[Importer,AnkiPackageImporter, TextImporter]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportDialog(QDialog):
|
||||||
|
_DEFAULT_FILE_DELIMITER = "\t"
|
||||||
|
|
||||||
|
def __init__(self, mw: aqt.main.AnkiQt, path: str) -> None:
|
||||||
|
QDialog.__init__(self, mw, Qt.WindowType.Window)
|
||||||
|
self.mw = mw
|
||||||
|
self.path = path
|
||||||
|
self.frm = aqt.forms.importing.Ui_ImportDialog()
|
||||||
|
self.frm.setupUi(self)
|
||||||
|
qconnect(
|
||||||
|
self.frm.buttonBox.button(QDialogButtonBox.StandardButton.Help).clicked,
|
||||||
|
self.helpRequested,
|
||||||
|
)
|
||||||
|
disable_help_button(self)
|
||||||
|
self.setupMappingFrame()
|
||||||
|
self.setupOptions()
|
||||||
|
self.modelChanged()
|
||||||
|
qconnect(self.frm.autoDetect.clicked, self.onDelimiter)
|
||||||
|
self.updateDelimiterButtonText(self._DEFAULT_FILE_DELIMITER)
|
||||||
|
self.frm.allowHTML.setChecked(self.mw.pm.profile.get("allowHTML", True))
|
||||||
|
qconnect(self.frm.importMode.currentIndexChanged, self.importModeChanged)
|
||||||
|
self.frm.importMode.setCurrentIndex(self.mw.pm.profile.get("importMode", 1))
|
||||||
|
self.frm.tagModified.setText(self.mw.pm.profile.get("tagModified", ""))
|
||||||
|
self.frm.tagModified.setCol(self.mw.col)
|
||||||
|
# import button
|
||||||
|
b = QPushButton(tr.actions_import())
|
||||||
|
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||||
|
self.exec()
|
||||||
|
|
||||||
|
def setupOptions(self) -> None:
|
||||||
|
import aqt.deckchooser
|
||||||
|
import aqt.modelchooser
|
||||||
|
|
||||||
|
self.model = self.mw.col.models.current()
|
||||||
|
self.modelChooser = aqt.modelchooser.ModelChooser(
|
||||||
|
self.mw, self.frm.modelArea, label=False
|
||||||
|
)
|
||||||
|
self.deck = aqt.deckchooser.DeckChooser(self.mw, self.frm.deckArea, label=False)
|
||||||
|
|
||||||
|
def modelChanged(self, unused: Any = None) -> None:
|
||||||
|
self.showMapping()
|
||||||
|
|
||||||
|
def onDelimiter(self) -> None:
|
||||||
|
|
||||||
|
# Open a modal dialog to enter an delimiter
|
||||||
|
# Todo/Idea Constrain the maximum width, so it doesnt take up that much screen space
|
||||||
|
delim, ok = getText(
|
||||||
|
tr.importing_by_default_anki_will_detect_the(),
|
||||||
|
self,
|
||||||
|
help=HelpPage.IMPORTING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the modal dialog has been confirmed, update the delimiter
|
||||||
|
if ok:
|
||||||
|
# Check if the entered value is valid and if not fallback to default
|
||||||
|
# at the moment every single character entry as well as '\t' is valid
|
||||||
|
|
||||||
|
delim = delim if len(delim) > 0 else self._DEFAULT_FILE_DELIMITER
|
||||||
|
delim = delim.replace("\\t", "\t") # un-escape it
|
||||||
|
if len(delim) > 1:
|
||||||
|
showWarning(
|
||||||
|
tr.importing_multicharacter_separators_are_not_supported_please()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.hideMapping()
|
||||||
|
|
||||||
|
def updateDelim() -> None:
|
||||||
|
self.updateDelimiterButtonText(delim)
|
||||||
|
|
||||||
|
self.showMapping(hook=updateDelim)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# If the operation has been canceled, do not do anything
|
||||||
|
pass
|
||||||
|
|
||||||
|
def updateDelimiterButtonText(self, d: str) -> None:
|
||||||
|
if d == "\t":
|
||||||
|
d = tr.importing_tab()
|
||||||
|
elif d == ",":
|
||||||
|
d = tr.importing_comma()
|
||||||
|
elif d == " ":
|
||||||
|
d = tr.studying_space()
|
||||||
|
elif d == ";":
|
||||||
|
d = tr.importing_semicolon()
|
||||||
|
elif d == ":":
|
||||||
|
d = tr.importing_colon()
|
||||||
|
else:
|
||||||
|
d = repr(d)
|
||||||
|
txt = tr.importing_fields_separated_by(val=d)
|
||||||
|
self.frm.autoDetect.setText(txt)
|
||||||
|
self.delim = d
|
||||||
|
|
||||||
|
def accept(self) -> None:
|
||||||
|
# self.mw.pm.profile["importMode"] = self.importer.importMode
|
||||||
|
self.mw.pm.profile["allowHTML"] = self.frm.allowHTML.isChecked()
|
||||||
|
# self.mw.pm.profile["tagModified"] = self.importer.tagModified
|
||||||
|
self.mw.col.set_aux_notetype_config(
|
||||||
|
self.model["id"], "lastDeck", self.deck.selected_deck_id
|
||||||
|
)
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
CollectionOp(
|
||||||
|
parent=self.mw,
|
||||||
|
op=lambda col: col.import_csv(
|
||||||
|
path=self.path,
|
||||||
|
deck_id=self.deck.selected_deck_id,
|
||||||
|
notetype_id=self.model["id"],
|
||||||
|
delimiter=self.delim,
|
||||||
|
columns=self.columns(),
|
||||||
|
allow_html=self.frm.allowHTML.isChecked(),
|
||||||
|
),
|
||||||
|
).with_backend_progress(import_progress_update).success(
|
||||||
|
show_import_log
|
||||||
|
).run_in_background()
|
||||||
|
|
||||||
|
def setupMappingFrame(self) -> None:
|
||||||
|
# qt seems to have a bug with adding/removing from a grid, so we add
|
||||||
|
# to a separate object and add/remove that instead
|
||||||
|
self.frame = QFrame(self.frm.mappingArea)
|
||||||
|
self.frm.mappingArea.setWidget(self.frame)
|
||||||
|
self.mapbox = QVBoxLayout(self.frame)
|
||||||
|
self.mapbox.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.mapwidget: Optional[QWidget] = None
|
||||||
|
|
||||||
|
def hideMapping(self) -> None:
|
||||||
|
self.frm.mappingGroup.hide()
|
||||||
|
|
||||||
|
def showMapping(
|
||||||
|
self, keepMapping: bool = False, hook: Optional[Callable] = None
|
||||||
|
) -> None:
|
||||||
|
if hook:
|
||||||
|
hook()
|
||||||
|
if not keepMapping:
|
||||||
|
self.mapping = [f["name"] for f in self.model["flds"]] + ["_tags"] + [None]
|
||||||
|
self.frm.mappingGroup.show()
|
||||||
|
# set up the mapping grid
|
||||||
|
if self.mapwidget:
|
||||||
|
self.mapbox.removeWidget(self.mapwidget)
|
||||||
|
self.mapwidget.deleteLater()
|
||||||
|
self.mapwidget = QWidget()
|
||||||
|
self.mapbox.addWidget(self.mapwidget)
|
||||||
|
self.grid = QGridLayout(self.mapwidget)
|
||||||
|
self.mapwidget.setLayout(self.grid)
|
||||||
|
self.grid.setContentsMargins(3, 3, 3, 3)
|
||||||
|
self.grid.setSpacing(6)
|
||||||
|
for (num, value) in enumerate(self.mapping):
|
||||||
|
text = tr.importing_field_of_file_is(val=num + 1)
|
||||||
|
self.grid.addWidget(QLabel(text), num, 0)
|
||||||
|
if value == "_tags":
|
||||||
|
text = tr.importing_mapped_to_tags()
|
||||||
|
elif value:
|
||||||
|
text = tr.importing_mapped_to(val=value)
|
||||||
|
else:
|
||||||
|
text = tr.importing_ignored()
|
||||||
|
self.grid.addWidget(QLabel(text), num, 1)
|
||||||
|
button = QPushButton(tr.importing_change())
|
||||||
|
self.grid.addWidget(button, num, 2)
|
||||||
|
qconnect(button.clicked, lambda _, s=self, n=num: s.changeMappingNum(n))
|
||||||
|
|
||||||
|
def changeMappingNum(self, n: int) -> None:
|
||||||
|
f = ChangeMap(self.mw, self.model, self.mapping[n]).getField()
|
||||||
|
try:
|
||||||
|
# make sure we don't have it twice
|
||||||
|
index = self.mapping.index(f)
|
||||||
|
self.mapping[index] = None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self.mapping[n] = f
|
||||||
|
self.showMapping(keepMapping=True)
|
||||||
|
|
||||||
|
def reject(self) -> None:
|
||||||
|
self.modelChooser.cleanup()
|
||||||
|
self.deck.cleanup()
|
||||||
|
QDialog.reject(self)
|
||||||
|
|
||||||
|
def helpRequested(self) -> None:
|
||||||
|
openHelp(HelpPage.IMPORTING)
|
||||||
|
|
||||||
|
def importModeChanged(self, newImportMode: int) -> None:
|
||||||
|
if newImportMode == 0:
|
||||||
|
self.frm.tagModified.setEnabled(True)
|
||||||
|
else:
|
||||||
|
self.frm.tagModified.setEnabled(False)
|
||||||
|
|
||||||
|
def columns(self) -> list[CsvColumn]:
|
||||||
|
return [self.column_for_value(value) for value in self.mapping]
|
||||||
|
|
||||||
|
def column_for_value(self, value: str) -> CsvColumn:
|
||||||
|
if value == "_tags":
|
||||||
|
return CsvColumn(other=CsvColumn.TAGS)
|
||||||
|
elif value is None:
|
||||||
|
return CsvColumn(other=CsvColumn.IGNORE)
|
||||||
|
else:
|
||||||
|
return CsvColumn(field=[f["name"] for f in self.model["flds"]].index(value))
|
||||||
|
|
||||||
|
|
||||||
|
def showUnicodeWarning() -> None:
|
||||||
|
"""Shorthand to show a standard warning."""
|
||||||
|
showWarning(tr.importing_selected_file_was_not_in_utf8())
|
|
@ -8,6 +8,7 @@ from itertools import chain
|
||||||
import aqt.main
|
import aqt.main
|
||||||
from anki.collection import Collection, ImportLogWithChanges, Progress
|
from anki.collection import Collection, ImportLogWithChanges, Progress
|
||||||
from anki.errors import Interrupted
|
from anki.errors import Interrupted
|
||||||
|
from anki.foreign_data import mnemosyne
|
||||||
from aqt.operations import CollectionOp, QueryOp
|
from aqt.operations import CollectionOp, QueryOp
|
||||||
from aqt.progress import ProgressUpdate
|
from aqt.progress import ProgressUpdate
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
|
@ -24,12 +25,12 @@ def import_file(mw: aqt.main.AnkiQt, path: str) -> None:
|
||||||
maybe_import_collection_package(mw, path)
|
maybe_import_collection_package(mw, path)
|
||||||
elif filename.endswith(".apkg") or filename.endswith(".zip"):
|
elif filename.endswith(".apkg") or filename.endswith(".zip"):
|
||||||
import_anki_package(mw, path)
|
import_anki_package(mw, path)
|
||||||
|
elif filename.endswith(".db"):
|
||||||
|
import_mnemosyne(mw, path)
|
||||||
else:
|
else:
|
||||||
showWarning(
|
import aqt.import_export.import_dialog
|
||||||
tr.importing_unable_to_import_filename(filename=filename),
|
|
||||||
parent=mw,
|
aqt.import_export.import_dialog.ImportDialog(mw, path)
|
||||||
textFormat="plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_for_file_then_import(mw: aqt.main.AnkiQt) -> None:
|
def prompt_for_file_then_import(mw: aqt.main.AnkiQt) -> None:
|
||||||
|
@ -39,16 +40,22 @@ def prompt_for_file_then_import(mw: aqt.main.AnkiQt) -> None:
|
||||||
|
|
||||||
def get_file_path(mw: aqt.main.AnkiQt) -> str | None:
|
def get_file_path(mw: aqt.main.AnkiQt) -> str | None:
|
||||||
if file := getFile(
|
if file := getFile(
|
||||||
mw,
|
mw, tr.actions_import(), None, key="import", filter=file_filter()
|
||||||
tr.actions_import(),
|
|
||||||
None,
|
|
||||||
key="import",
|
|
||||||
filter=tr.importing_packaged_anki_deckcollection_apkg_colpkg_zip(),
|
|
||||||
):
|
):
|
||||||
return str(file)
|
return str(file)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def file_filter() -> str:
|
||||||
|
return ";;".join(
|
||||||
|
(
|
||||||
|
tr.importing_packaged_anki_deckcollection_apkg_colpkg_zip(),
|
||||||
|
tr.importing_text_separated_by_tabs_or_semicolons(),
|
||||||
|
tr.importing_mnemosyne_20_deck_db(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_collection_package(filename: str) -> bool:
|
def is_collection_package(filename: str) -> bool:
|
||||||
return (
|
return (
|
||||||
filename == "collection.apkg"
|
filename == "collection.apkg"
|
||||||
|
@ -115,6 +122,20 @@ def import_anki_package(mw: aqt.main.AnkiQt, path: str) -> None:
|
||||||
).run_in_background()
|
).run_in_background()
|
||||||
|
|
||||||
|
|
||||||
|
def import_mnemosyne(mw: aqt.main.AnkiQt, path: str) -> None:
|
||||||
|
QueryOp(
|
||||||
|
parent=mw,
|
||||||
|
op=lambda _: mnemosyne.serialize(path),
|
||||||
|
success=lambda json: import_json(mw, json),
|
||||||
|
).with_progress().run_in_background()
|
||||||
|
|
||||||
|
|
||||||
|
def import_json(mw: aqt.main.AnkiQt, json: str) -> None:
|
||||||
|
CollectionOp(parent=mw, op=lambda col: col.import_json(json)).with_backend_progress(
|
||||||
|
import_progress_update
|
||||||
|
).success(show_import_log).run_in_background()
|
||||||
|
|
||||||
|
|
||||||
def show_import_log(log_with_changes: ImportLogWithChanges) -> None:
|
def show_import_log(log_with_changes: ImportLogWithChanges) -> None:
|
||||||
showText(stringify_log(log_with_changes.log), plain_text_edit=True)
|
showText(stringify_log(log_with_changes.log), plain_text_edit=True)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue