mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 01:06:35 -04:00
Add csv importing with GUI
This commit is contained in:
parent
2c3a6a43de
commit
3a175c5434
6 changed files with 142 additions and 89 deletions
|
@ -24,6 +24,7 @@ ignored-classes=
|
||||||
ScheduleCardsAsNewRequest,
|
ScheduleCardsAsNewRequest,
|
||||||
ExportAnkiPackageRequest,
|
ExportAnkiPackageRequest,
|
||||||
CsvColumn,
|
CsvColumn,
|
||||||
|
CsvMetadata,
|
||||||
|
|
||||||
[REPORTS]
|
[REPORTS]
|
||||||
output-format=colorized
|
output-format=colorized
|
||||||
|
|
|
@ -35,6 +35,7 @@ BrowserColumns = search_pb2.BrowserColumns
|
||||||
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
|
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
|
||||||
ImportLogWithChanges = import_export_pb2.ImportResponse
|
ImportLogWithChanges = import_export_pb2.ImportResponse
|
||||||
CsvColumn = import_export_pb2.ImportCsvRequest.CsvColumn
|
CsvColumn = import_export_pb2.ImportCsvRequest.CsvColumn
|
||||||
|
CsvMetadata = import_export_pb2.CsvMetadata
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import os
|
import os
|
||||||
|
@ -404,6 +405,10 @@ 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 get_csv_metadata(self, path: str, delimiter: int | None) -> CsvMetadata:
|
||||||
|
request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter)
|
||||||
|
return self._backend.get_csv_metadata(request)
|
||||||
|
|
||||||
def import_csv(
|
def import_csv(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
|
|
|
@ -19,6 +19,7 @@ ignored-classes=
|
||||||
Cram,
|
Cram,
|
||||||
ScheduleCardsAsNewRequest,
|
ScheduleCardsAsNewRequest,
|
||||||
CsvColumn,
|
CsvColumn,
|
||||||
|
CsvMetadata,
|
||||||
|
|
||||||
[REPORTS]
|
[REPORTS]
|
||||||
output-format=colorized
|
output-format=colorized
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
../../../../.prettierrc
|
../../../../.prettierrc
|
||||||
|
|
|
@ -5,13 +5,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
import aqt.forms
|
import aqt.forms
|
||||||
import aqt.main
|
import aqt.main
|
||||||
from anki.collection import CsvColumn
|
from anki.collection import CsvColumn, CsvMetadata
|
||||||
|
from anki.decks import DeckId
|
||||||
|
from anki.models import NotetypeDict, NotetypeId
|
||||||
from aqt.import_export.importing import import_progress_update, show_import_log
|
from aqt.import_export.importing import import_progress_update, show_import_log
|
||||||
from aqt.operations import CollectionOp
|
from aqt.operations import CollectionOp, QueryOp
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import HelpPage, disable_help_button, getText, openHelp, showWarning, tr
|
from aqt.utils import HelpPage, disable_help_button, getText, openHelp, showWarning, tr
|
||||||
|
|
||||||
|
@ -65,12 +67,19 @@ class ChangeMap(QDialog):
|
||||||
|
|
||||||
|
|
||||||
class ImportDialog(QDialog):
|
class ImportDialog(QDialog):
|
||||||
_DEFAULT_FILE_DELIMITER = "\t"
|
|
||||||
|
|
||||||
def __init__(self, mw: aqt.main.AnkiQt, path: str) -> None:
|
def __init__(self, mw: aqt.main.AnkiQt, path: str) -> None:
|
||||||
QDialog.__init__(self, mw, Qt.WindowType.Window)
|
QDialog.__init__(self, mw, Qt.WindowType.Window)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.options = CsvMetadata()
|
||||||
|
QueryOp(
|
||||||
|
parent=self,
|
||||||
|
op=lambda col: col.get_csv_metadata(path, None),
|
||||||
|
success=self._run,
|
||||||
|
).run_in_background()
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
self.frm = aqt.forms.importing.Ui_ImportDialog()
|
self.frm = aqt.forms.importing.Ui_ImportDialog()
|
||||||
self.frm.setupUi(self)
|
self.frm.setupUi(self)
|
||||||
qconnect(
|
qconnect(
|
||||||
|
@ -79,35 +88,61 @@ class ImportDialog(QDialog):
|
||||||
)
|
)
|
||||||
disable_help_button(self)
|
disable_help_button(self)
|
||||||
self.setupMappingFrame()
|
self.setupMappingFrame()
|
||||||
self.setupOptions()
|
|
||||||
self.modelChanged()
|
|
||||||
qconnect(self.frm.autoDetect.clicked, self.onDelimiter)
|
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)
|
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
|
# import button
|
||||||
b = QPushButton(tr.actions_import())
|
b = QPushButton(tr.actions_import())
|
||||||
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
|
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||||
self.exec()
|
|
||||||
|
|
||||||
def setupOptions(self) -> None:
|
def _run(self, options: CsvMetadata) -> None:
|
||||||
|
self._setup_options(options)
|
||||||
|
self._setup_choosers()
|
||||||
|
self.column_map = ColumnMap(self.columns, self.model)
|
||||||
|
self._render_mapping()
|
||||||
|
self._set_delimiter_button_text()
|
||||||
|
self.frm.allowHTML.setChecked(self.html)
|
||||||
|
self.frm.importMode.setCurrentIndex(self.mw.pm.profile.get("importMode", 1))
|
||||||
|
self.frm.tagModified.setText(self.tags)
|
||||||
|
self.frm.tagModified.setCol(self.mw.col)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def _setup_options(self, options: CsvMetadata) -> None:
|
||||||
|
self.delimiter = options.delimiter
|
||||||
|
self.tags = self.options.tags or self.mw.pm.profile.get("tagModified", "")
|
||||||
|
self.columns = options.columns
|
||||||
|
self.deck_id = DeckId(
|
||||||
|
self.options.deck_id or self.mw.col.get_config("curDeck", default=1)
|
||||||
|
)
|
||||||
|
if options.notetype_id:
|
||||||
|
self.notetype_id = NotetypeId(self.options.notetype_id)
|
||||||
|
self.model = self.mw.col.models.get(self.notetype_id)
|
||||||
|
else:
|
||||||
|
self.model = self.mw.col.models.current()
|
||||||
|
self.notetype_id = self.model["id"]
|
||||||
|
if self.options.html is None:
|
||||||
|
self.html = self.mw.pm.profile.get("allowHTML", True)
|
||||||
|
else:
|
||||||
|
self.html = self.options.html
|
||||||
|
|
||||||
|
def _setup_choosers(self) -> None:
|
||||||
import aqt.deckchooser
|
import aqt.deckchooser
|
||||||
import aqt.modelchooser
|
import aqt.notetypechooser
|
||||||
|
|
||||||
self.model = self.mw.col.models.current()
|
def change_notetype(ntid: NotetypeId) -> None:
|
||||||
self.modelChooser = aqt.modelchooser.ModelChooser(
|
self.model = self.mw.col.models.get(ntid)
|
||||||
self.mw, self.frm.modelArea, label=False
|
self.notetype_id = ntid
|
||||||
|
self.column_map = ColumnMap(self.columns, self.model)
|
||||||
|
self._render_mapping()
|
||||||
|
|
||||||
|
self.modelChooser = aqt.notetypechooser.NotetypeChooser(
|
||||||
|
mw=self.mw,
|
||||||
|
widget=self.frm.modelArea,
|
||||||
|
starting_notetype_id=self.notetype_id,
|
||||||
|
on_notetype_changed=change_notetype,
|
||||||
)
|
)
|
||||||
self.deck = aqt.deckchooser.DeckChooser(self.mw, self.frm.deckArea, 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:
|
def onDelimiter(self) -> None:
|
||||||
|
|
||||||
# Open a modal dialog to enter an delimiter
|
# Open a modal dialog to enter an delimiter
|
||||||
# Todo/Idea Constrain the maximum width, so it doesnt take up that much screen space
|
# Todo/Idea Constrain the maximum width, so it doesnt take up that much screen space
|
||||||
delim, ok = getText(
|
delim, ok = getText(
|
||||||
|
@ -116,30 +151,37 @@ class ImportDialog(QDialog):
|
||||||
help=HelpPage.IMPORTING,
|
help=HelpPage.IMPORTING,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the modal dialog has been confirmed, update the delimiter
|
if not ok:
|
||||||
if ok:
|
return
|
||||||
# Check if the entered value is valid and if not fallback to default
|
# 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
|
# at the moment every single character entry as well as '\t' is valid
|
||||||
|
delim = delim if len(delim) > 0 else "\t"
|
||||||
|
delim = delim.replace("\\t", "\t") # un-escape it
|
||||||
|
delimiter = ord(delim)
|
||||||
|
if delimiter > 255:
|
||||||
|
showWarning(
|
||||||
|
tr.importing_multicharacter_separators_are_not_supported_please()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
delim = delim if len(delim) > 0 else self._DEFAULT_FILE_DELIMITER
|
# self.hideMapping()
|
||||||
delim = delim.replace("\\t", "\t") # un-escape it
|
# self.showMapping(hook=_update)
|
||||||
if len(delim) > 1:
|
self.delimiter = delimiter
|
||||||
showWarning(
|
self._set_delimiter_button_text()
|
||||||
tr.importing_multicharacter_separators_are_not_supported_please()
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self.hideMapping()
|
|
||||||
|
|
||||||
def updateDelim() -> None:
|
def _update_columns(options: CsvMetadata) -> None:
|
||||||
self.updateDelimiterButtonText(delim)
|
self.columns = options.columns
|
||||||
|
self.column_map = ColumnMap(self.columns, self.model)
|
||||||
|
self._render_mapping()
|
||||||
|
|
||||||
self.showMapping(hook=updateDelim)
|
QueryOp(
|
||||||
|
parent=self,
|
||||||
|
op=lambda col: col.get_csv_metadata(self.path, delimiter),
|
||||||
|
success=_update_columns,
|
||||||
|
).run_in_background()
|
||||||
|
|
||||||
else:
|
def _set_delimiter_button_text(self) -> None:
|
||||||
# If the operation has been canceled, do not do anything
|
d = chr(self.delimiter)
|
||||||
pass
|
|
||||||
|
|
||||||
def updateDelimiterButtonText(self, d: str) -> None:
|
|
||||||
if d == "\t":
|
if d == "\t":
|
||||||
d = tr.importing_tab()
|
d = tr.importing_tab()
|
||||||
elif d == ",":
|
elif d == ",":
|
||||||
|
@ -154,7 +196,6 @@ class ImportDialog(QDialog):
|
||||||
d = repr(d)
|
d = repr(d)
|
||||||
txt = tr.importing_fields_separated_by(val=d)
|
txt = tr.importing_fields_separated_by(val=d)
|
||||||
self.frm.autoDetect.setText(txt)
|
self.frm.autoDetect.setText(txt)
|
||||||
self.delim = ord(d)
|
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
# self.mw.pm.profile["importMode"] = self.importer.importMode
|
# self.mw.pm.profile["importMode"] = self.importer.importMode
|
||||||
|
@ -171,8 +212,8 @@ class ImportDialog(QDialog):
|
||||||
path=self.path,
|
path=self.path,
|
||||||
deck_id=self.deck.selected_deck_id,
|
deck_id=self.deck.selected_deck_id,
|
||||||
notetype_id=self.model["id"],
|
notetype_id=self.model["id"],
|
||||||
delimiter=self.delim,
|
delimiter=self.delimiter,
|
||||||
columns=self.columns(),
|
columns=self.column_map.csv_columns(),
|
||||||
allow_html=self.frm.allowHTML.isChecked(),
|
allow_html=self.frm.allowHTML.isChecked(),
|
||||||
),
|
),
|
||||||
).with_backend_progress(import_progress_update).success(
|
).with_backend_progress(import_progress_update).success(
|
||||||
|
@ -191,14 +232,7 @@ class ImportDialog(QDialog):
|
||||||
def hideMapping(self) -> None:
|
def hideMapping(self) -> None:
|
||||||
self.frm.mappingGroup.hide()
|
self.frm.mappingGroup.hide()
|
||||||
|
|
||||||
def showMapping(
|
def _render_mapping(self) -> None:
|
||||||
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
|
# set up the mapping grid
|
||||||
if self.mapwidget:
|
if self.mapwidget:
|
||||||
self.mapbox.removeWidget(self.mapwidget)
|
self.mapbox.removeWidget(self.mapwidget)
|
||||||
|
@ -209,30 +243,17 @@ class ImportDialog(QDialog):
|
||||||
self.mapwidget.setLayout(self.grid)
|
self.mapwidget.setLayout(self.grid)
|
||||||
self.grid.setContentsMargins(3, 3, 3, 3)
|
self.grid.setContentsMargins(3, 3, 3, 3)
|
||||||
self.grid.setSpacing(6)
|
self.grid.setSpacing(6)
|
||||||
for (num, value) in enumerate(self.mapping):
|
for (num, column) in enumerate(self.column_map.columns):
|
||||||
text = tr.importing_field_of_file_is(val=num + 1)
|
self.grid.addWidget(QLabel(column), num, 0)
|
||||||
self.grid.addWidget(QLabel(text), num, 0)
|
self.grid.addWidget(QLabel(self.column_map.map_label(num)), num, 1)
|
||||||
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())
|
button = QPushButton(tr.importing_change())
|
||||||
self.grid.addWidget(button, num, 2)
|
self.grid.addWidget(button, num, 2)
|
||||||
qconnect(button.clicked, lambda _, s=self, n=num: s.changeMappingNum(n))
|
qconnect(button.clicked, lambda _, s=self, n=num: s.changeMappingNum(n))
|
||||||
|
|
||||||
def changeMappingNum(self, n: int) -> None:
|
def changeMappingNum(self, n: int) -> None:
|
||||||
f = ChangeMap(self.mw, self.model, self.mapping[n]).getField()
|
f = ChangeMap(self.mw, self.model, self.column_map.map[n]).getField()
|
||||||
try:
|
self.column_map.update(n, f)
|
||||||
# make sure we don't have it twice
|
self._render_mapping()
|
||||||
index = self.mapping.index(f)
|
|
||||||
self.mapping[index] = None
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
self.mapping[n] = f
|
|
||||||
self.showMapping(keepMapping=True)
|
|
||||||
|
|
||||||
def reject(self) -> None:
|
def reject(self) -> None:
|
||||||
self.modelChooser.cleanup()
|
self.modelChooser.cleanup()
|
||||||
|
@ -248,18 +269,43 @@ class ImportDialog(QDialog):
|
||||||
else:
|
else:
|
||||||
self.frm.tagModified.setEnabled(False)
|
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:
|
class ColumnMap:
|
||||||
if value == "_tags":
|
columns: list[str]
|
||||||
return CsvColumn(other=CsvColumn.TAGS)
|
fields: list[str]
|
||||||
elif value is None:
|
map: list[str]
|
||||||
|
|
||||||
|
def __init__(self, columns: Sequence[str], notetype: NotetypeDict) -> None:
|
||||||
|
self.columns = list(columns)
|
||||||
|
self.fields = [f["name"] for f in notetype["flds"]] + ["_tags"]
|
||||||
|
self.map = [""] * len(self.columns)
|
||||||
|
for i in range(min(len(self.fields), len(self.columns))):
|
||||||
|
self.map[i] = self.fields[i]
|
||||||
|
|
||||||
|
def map_label(self, num: int) -> str:
|
||||||
|
name = self.map[num]
|
||||||
|
if not name:
|
||||||
|
return tr.importing_ignored()
|
||||||
|
if name == "_tags":
|
||||||
|
tr.importing_mapped_to_tags()
|
||||||
|
return tr.importing_mapped_to(val=name)
|
||||||
|
|
||||||
|
def update(self, column: int, new_field: str | None) -> None:
|
||||||
|
if new_field:
|
||||||
|
try:
|
||||||
|
idx = self.map.index(new_field)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.map[idx] = ""
|
||||||
|
self.map[column] = new_field or ""
|
||||||
|
|
||||||
|
def csv_columns(self) -> list[CsvColumn]:
|
||||||
|
return [self._column_for_name(name) for name in self.map]
|
||||||
|
|
||||||
|
def _column_for_name(self, name: str) -> CsvColumn:
|
||||||
|
if not name:
|
||||||
return CsvColumn(other=CsvColumn.IGNORE)
|
return CsvColumn(other=CsvColumn.IGNORE)
|
||||||
else:
|
if name == "_tags":
|
||||||
return CsvColumn(field=[f["name"] for f in self.model["flds"]].index(value))
|
return CsvColumn(other=CsvColumn.TAGS)
|
||||||
|
return CsvColumn(field=self.fields.index(name))
|
||||||
|
|
||||||
def showUnicodeWarning() -> None:
|
|
||||||
"""Shorthand to show a standard warning."""
|
|
||||||
showWarning(tr.importing_selected_file_was_not_in_utf8())
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
.cloze {
|
.cloze {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: blue;
|
color: blue;
|
||||||
}
|
}
|
||||||
.nightMode .cloze {
|
.nightMode .cloze {
|
||||||
color: lightblue;
|
color: lightblue;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue