Add start of new import csv screen

This commit is contained in:
RumovZ 2022-05-16 11:49:53 +02:00
parent 8ad0fdc732
commit 250a1fc495
27 changed files with 729 additions and 334 deletions

View file

@ -12,6 +12,7 @@ importing-colon = Colon
importing-column = Column { $val } importing-column = Column { $val }
importing-comma = Comma importing-comma = Comma
importing-empty-first-field = Empty first field: { $val } importing-empty-first-field = Empty first field: { $val }
importing-field-delimiter = Field delimiter
importing-field-mapping = Field mapping importing-field-mapping = Field mapping
importing-field-of-file-is = Field <b>{ $val }</b> of file is: importing-field-of-file-is = Field <b>{ $val }</b> of file is:
importing-fields-separated-by = Fields separated by: { $val } importing-fields-separated-by = Fields separated by: { $val }
@ -38,6 +39,7 @@ importing-notes-that-could-not-be-imported = Notes that could not be imported as
importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val } importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val }
importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip) importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip)
importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz) importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
importing-pipe = Pipe
importing-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } fields, expected { $expected } importing-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } fields, expected { $expected }
importing-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual. importing-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual.
importing-semicolon = Semicolon importing-semicolon = Semicolon

View file

@ -1,3 +1,5 @@
notetypes-notetype = Notetype
## Default field names in newly created note types ## Default field names in newly created note types
notetypes-front-field = Front notetypes-front-field = Front

View file

@ -27,6 +27,7 @@ service NotetypesService {
rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoRequest) rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoRequest)
returns (ChangeNotetypeInfo); returns (ChangeNotetypeInfo);
rpc ChangeNotetype(ChangeNotetypeRequest) returns (collection.OpChanges); rpc ChangeNotetype(ChangeNotetypeRequest) returns (collection.OpChanges);
rpc GetFieldNames(NotetypeId) returns (generic.StringList);
} }
message NotetypeId { message NotetypeId {

View file

@ -34,7 +34,7 @@ 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.ImportResponse ImportLogWithChanges = import_export_pb2.ImportResponse
CsvColumn = import_export_pb2.ImportCsvRequest.CsvColumn ImportCsvRequest = import_export_pb2.ImportCsvRequest
CsvMetadata = import_export_pb2.CsvMetadata CsvMetadata = import_export_pb2.CsvMetadata
Delimiter = import_export_pb2.CsvMetadata.Delimiter Delimiter = import_export_pb2.CsvMetadata.Delimiter
@ -410,23 +410,9 @@ class Collection(DeprecatedNamesMixin):
request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter) request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter)
return self._backend.get_csv_metadata(request) return self._backend.get_csv_metadata(request)
def import_csv( def import_csv(self, request: ImportCsvRequest) -> ImportLogWithChanges:
self, log = self._backend.import_csv_raw(request.SerializeToString())
path: str, return ImportLogWithChanges.FromString(log)
deck_id: DeckId,
notetype_id: NotetypeId,
columns: list[CsvColumn],
delimiter: Delimiter.V,
is_html: bool,
) -> ImportLogWithChanges:
return self._backend.import_csv(
path=path,
deck_id=deck_id,
notetype_id=notetype_id,
delimiter=delimiter,
columns=columns,
is_html=is_html,
)
def import_json_file(self, path: str) -> ImportLogWithChanges: def import_json_file(self, path: str) -> ImportLogWithChanges:
return self._backend.import_json_file(path) return self._backend.import_json_file(path)

View file

@ -7,6 +7,7 @@ _pages = [
"change-notetype", "change-notetype",
"card-info", "card-info",
"fields", "fields",
"import-csv",
] ]
[copy_files_into_group( [copy_files_into_group(

View file

@ -0,0 +1,62 @@
# 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 aqt
import aqt.deckconf
import aqt.main
import aqt.operations
from anki.collection import ImportCsvRequest
from aqt.qt import *
from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr
from aqt.webview import AnkiWebView
class ImportCsvDialog(QDialog):
TITLE = "csv import"
silentlyClose = True
def __init__(
self,
mw: aqt.main.AnkiQt,
path: str,
on_accepted: Callable[[ImportCsvRequest], None],
) -> None:
QDialog.__init__(self, mw)
self.mw = mw
self._on_accepted = on_accepted
self._setup_ui(path)
self.show()
def _setup_ui(self, path: str) -> None:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.mw.garbage_collect_on_dialog_finish(self)
self.setMinimumSize(400, 300)
disable_help_button(self)
restoreGeom(self, self.TITLE)
addCloseShortcut(self)
self.web = AnkiWebView(title=self.TITLE)
self.web.setVisible(False)
self.web.load_ts_page("import-csv")
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.web)
self.setLayout(layout)
self.web.eval(f"anki.setupImportCsvPage('{path}');")
self.setWindowTitle(tr.decks_import_file())
def reject(self) -> None:
self.web.cleanup()
self.web = None
saveGeom(self, self.TITLE)
QDialog.reject(self)
def do_import(self, data: bytes) -> None:
request = ImportCsvRequest()
request.ParseFromString(data)
self._on_accepted(request)
super().reject()

View file

@ -1,310 +0,0 @@
# 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 Optional, Sequence
import aqt.forms
import aqt.main
from anki.collection import CsvColumn, CsvMetadata, Delimiter
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.operations import CollectionOp, QueryOp
from aqt.qt import *
from aqt.utils import HelpPage, disable_help_button, getText, openHelp, showWarning, tr
DELIMITERS = (
("\\t", tr.importing_tab(), Delimiter.TAB),
(",", tr.importing_comma(), Delimiter.COMMA),
(" ", tr.studying_space(), Delimiter.SPACE),
(";", tr.importing_semicolon(), Delimiter.SEMICOLON),
(":", tr.importing_colon(), Delimiter.COLON),
("|", tr.importing_colon(), Delimiter.PIPE),
)
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):
def __init__(self, mw: aqt.main.AnkiQt, path: str) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
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.setupUi(self)
qconnect(
self.frm.buttonBox.button(QDialogButtonBox.StandardButton.Help).clicked,
self.helpRequested,
)
disable_help_button(self)
self.setupMappingFrame()
qconnect(self.frm.autoDetect.clicked, self.onDelimiter)
qconnect(self.frm.importMode.currentIndexChanged, self.importModeChanged)
# import button
b = QPushButton(tr.actions_import())
self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)
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()
self.frm.allowHTML.setChecked(self.is_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.is_html is None:
self.is_html = self.mw.pm.profile.get("allowHTML", True)
else:
self.is_html = self.options.is_html
def _setup_choosers(self) -> None:
import aqt.deckchooser
import aqt.notetypechooser
def change_notetype(ntid: NotetypeId) -> None:
self.model = self.mw.col.models.get(ntid)
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)
def _set_delimiter(self) -> None:
for delimiter in DELIMITERS:
if delimiter[2] == self.delimiter:
txt = tr.importing_fields_separated_by(val=delimiter[1])
self.frm.autoDetect.setText(txt)
return
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 not ok:
return
# Check if the entered value is valid and if not fallback to default
txt = ""
for delimiter in DELIMITERS:
if delimiter[0] == delim:
txt = tr.importing_fields_separated_by(val=delimiter[1])
self.delimiter = delimiter[2]
break
if not txt:
showWarning(
tr.importing_multicharacter_separators_are_not_supported_please()
)
return
self.frm.autoDetect.setText(txt)
def _update_columns(options: CsvMetadata) -> None:
self.columns = options.columns
self.column_map = ColumnMap(self.columns, self.model)
self._render_mapping()
QueryOp(
parent=self,
op=lambda col: col.get_csv_metadata(self.path, self.delimiter),
success=_update_columns,
).run_in_background()
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.delimiter,
columns=self.column_map.csv_columns(),
is_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 _render_mapping(self) -> None:
# 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, column) in enumerate(self.column_map.columns):
self.grid.addWidget(QLabel(column), num, 0)
self.grid.addWidget(QLabel(self.column_map.map_label(num)), 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.column_map.map[n]).getField()
self.column_map.update(n, f)
self._render_mapping()
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)
class ColumnMap:
columns: list[str]
fields: list[str]
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)
if name == "_tags":
return CsvColumn(other=CsvColumn.TAGS)
return CsvColumn(field=self.fields.index(name))

View file

@ -8,10 +8,11 @@ from itertools import chain
from typing import Type from typing import Type
import aqt.main import aqt.main
from anki.collection import Collection, ImportLogWithChanges, Progress from anki.collection import Collection, ImportCsvRequest, ImportLogWithChanges, Progress
from anki.errors import Interrupted from anki.errors import Interrupted
from anki.foreign_data import mnemosyne from anki.foreign_data import mnemosyne
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from aqt.import_export.import_csv_dialog import ImportCsvDialog
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 *
@ -105,9 +106,15 @@ class CsvImporter(Importer):
@staticmethod @staticmethod
def do_import(mw: aqt.main.AnkiQt, path: str) -> None: def do_import(mw: aqt.main.AnkiQt, path: str) -> None:
import aqt.import_export.import_dialog def on_accepted(request: ImportCsvRequest) -> None:
CollectionOp(
parent=mw,
op=lambda col: col.import_csv(request),
).with_backend_progress(import_progress_update).success(
show_import_log
).run_in_background()
aqt.import_export.import_dialog.ImportDialog(mw, path) ImportCsvDialog(mw, path, on_accepted)
class JsonImporter(Importer): class JsonImporter(Importer):

View file

@ -31,6 +31,7 @@ from anki.scheduler.v3 import NextStates
from anki.utils import dev_mode from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog from aqt.deckoptions import DeckOptionsDialog
from aqt.import_export.import_csv_dialog import ImportCsvDialog
from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.qt import * from aqt.qt import *
@ -438,6 +439,18 @@ def change_notetype() -> bytes:
return b"" return b""
def import_csv() -> bytes:
data = request.data
def handle_on_main() -> None:
window = aqt.mw.app.activeWindow()
if isinstance(window, ImportCsvDialog):
window.do_import(data)
aqt.mw.taskman.run_on_main(handle_on_main)
return b""
post_handler_list = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -445,13 +458,19 @@ post_handler_list = [
next_card_states, next_card_states,
set_next_card_states, set_next_card_states,
change_notetype, change_notetype,
import_csv,
] ]
exposed_backend_list = [ exposed_backend_list = [
# DeckService
"get_deck_names",
# I18nService # I18nService
"i18n_resources", "i18n_resources",
# ImportExportService
"get_csv_metadata",
# NotesService # NotesService
"get_field_names",
"get_note", "get_note",
# NotetypesService # NotetypesService
"get_notetype_names", "get_notetype_names",

View file

@ -168,9 +168,15 @@ impl NotetypesService for Backend {
.map(Into::into) .map(Into::into)
}) })
} }
fn change_notetype(&self, input: pb::ChangeNotetypeRequest) -> Result<pb::OpChanges> { fn change_notetype(&self, input: pb::ChangeNotetypeRequest) -> Result<pb::OpChanges> {
self.with_col(|col| col.change_notetype_of_notes(input.into()).map(Into::into)) self.with_col(|col| col.change_notetype_of_notes(input.into()).map(Into::into))
} }
fn get_field_names(&self, input: pb::NotetypeId) -> Result<pb::StringList> {
self.with_col(|col| col.storage.get_field_names(input.into()))
.map(Into::into)
}
} }
impl From<pb::Notetype> for Notetype { impl From<pb::Notetype> for Notetype {

View file

@ -130,9 +130,11 @@ impl Collection {
fn column_label(&self, idx: usize, column: &str) -> String { fn column_label(&self, idx: usize, column: &str) -> String {
match column.trim() { match column.trim() {
"" => self.tr.importing_column(idx + 1).to_string(), "" => self.tr.importing_column(idx + 1).into(),
"tags" => self.tr.editing_tags().to_string(), "tags" => self.tr.editing_tags().into(),
s => s.to_string(), "notetype" => self.tr.notetypes_notetype().into(),
"deck" => self.tr.decks_deck().into(),
s => s.into(),
} }
} }

View file

@ -374,4 +374,12 @@ impl SqliteStorage {
self.db.execute("update col set models = ?", [json])?; self.db.execute("update col set models = ?", [json])?;
Ok(()) Ok(())
} }
pub(crate) fn get_field_names(&self, notetype_id: NotetypeId) -> Result<Vec<String>> {
self.db
.prepare_cached("SELECT name FROM fields WHERE ntid = ? ORDER BY ord")?
.query_and_then([notetype_id], |row| Ok(row.get(0)?))?
//.map_err(Into::into)
.collect()
}
} }

87
ts/import-csv/BUILD.bazel Normal file
View file

@ -0,0 +1,87 @@
load("//ts:prettier.bzl", "prettier_test")
load("//ts:eslint.bzl", "eslint_test")
load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte_check")
load("//ts:esbuild.bzl", "esbuild")
load("//ts:generate_page.bzl", "generate_page")
load("//ts:compile_sass.bzl", "compile_sass")
load("//ts:typescript.bzl", "typescript")
load("//ts:jest.bzl", "jest_test")
generate_page(page = "import-csv")
compile_sass(
srcs = ["import-csv-base.scss"],
group = "base_css",
visibility = ["//visibility:public"],
deps = [
"//sass:base_lib",
"//sass:scrollbar_lib",
"//sass/bootstrap",
],
)
_ts_deps = [
"//ts/components",
"//ts/lib",
"//ts/sveltelib",
"@npm//@fluent",
"@npm//@types/jest",
"@npm//lodash-es",
"@npm//svelte",
"@npm//marked",
]
compile_svelte(deps = _ts_deps)
typescript(
name = "index",
deps = _ts_deps + [
":svelte",
],
)
esbuild(
name = "import-csv",
args = {
"globalName": "anki",
"loader": {".svg": "text"},
},
entry_point = "index.ts",
output_css = "import-csv.css",
visibility = ["//visibility:public"],
deps = [
":base_css",
":index",
":svelte",
"@npm//bootstrap-icons",
],
)
# Tests
################
prettier_test()
eslint_test()
svelte_check(
name = "svelte_check",
srcs = glob([
"*.ts",
"*.svelte",
]) + [
"//sass:button_mixins_lib",
"//sass/bootstrap",
"@npm//@types/bootstrap",
"@npm//@types/lodash-es",
"@npm//@types/marked",
"//ts/components",
],
)
jest_test(
protobuf = True,
deps = [
":index",
],
)

View file

@ -0,0 +1,38 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "../components/ButtonGroup.svelte";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import SelectButton from "../components/SelectButton.svelte";
import SelectOption from "../components/SelectOption.svelte";
import * as tr from "../lib/ftl";
import type { Decks } from "../lib/proto";
export let deckNameIds: Decks.DeckNameId[];
export let deckId: number;
function updateCurrentId(event: Event) {
const index = parseInt((event.target! as HTMLSelectElement).value);
deckId = deckNameIds[index].id;
}
</script>
<Row --cols={2}>
<Col --col-size={1}>
<div>{tr.decksDeck()}</div>
</Col>
<Col --col-size={1}>
<ButtonGroup class="flex-grow-1">
<SelectButton class="flex-grow-1" on:change={updateCurrentId}>
{#each deckNameIds as entry, idx}
<SelectOption value={String(idx)} selected={entry.id === deckId}>
{entry.name}
</SelectOption>
{/each}
</SelectButton>
</ButtonGroup>
</Col>
</Row>

View file

@ -0,0 +1,54 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "../components/ButtonGroup.svelte";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import SelectButton from "../components/SelectButton.svelte";
import SelectOption from "../components/SelectOption.svelte";
import * as tr from "../lib/ftl";
import { ImportExport } from "../lib/proto";
export let delimiter: ImportExport.CsvMetadata.Delimiter;
const delimiters = delimiterNames();
function updateCurrentDelimiter(event: Event) {
const index = parseInt((event.target! as HTMLSelectElement).value);
delimiter = delimiters[index][0];
}
function delimiterNames(): [ImportExport.CsvMetadata.Delimiter, string][] {
const Delimiter = ImportExport.CsvMetadata.Delimiter;
return [
[Delimiter.TAB, tr.importingTab()],
[Delimiter.PIPE, tr.importingPipe()],
[Delimiter.SEMICOLON, tr.importingSemicolon()],
[Delimiter.COLON, tr.importingColon()],
[Delimiter.COMMA, tr.importingComma()],
[Delimiter.SPACE, tr.studyingSpace()],
];
}
</script>
<Row --cols={2}>
<Col --col-size={1}>
<div>{tr.importingFieldDelimiter()}</div>
</Col>
<Col --col-size={1}>
<ButtonGroup class="flex-grow-1">
<SelectButton class="flex-grow-1" on:change={updateCurrentDelimiter}>
{#each delimiters as delimiterName, idx}
<SelectOption
value={String(idx)}
selected={delimiterName[0] === delimiter}
>
{delimiterName[1]}
</SelectOption>
{/each}
</SelectButton>
</ButtonGroup>
</Col>
</Row>

View file

@ -0,0 +1,43 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Container from "../components/Container.svelte";
import Spacer from "../components/Spacer.svelte";
import * as tr from "../lib/ftl";
import { getNotetypeFields } from "./lib";
import MapperRow from "./MapperRow.svelte";
export let columnNames: string[];
export let notetypeId: number;
export let fieldColumnIndices: number[];
$: options = [tr.changeNotetypeNothing()].concat(columnNames);
let fieldNames: string[] = [];
$: {
getNotetypeFields(notetypeId).then((newFieldNames) => {
fieldNames = newFieldNames;
});
}
let firstFieldIndex: number = 0;
$: otherFieldIndices = Array(Math.max(0, fieldNames.length - 1))
.fill(0)
.map((_, i) => (i + 1 < columnNames.length ? i + 2 : 0));
$: fieldColumnIndices = [firstFieldIndex, ...otherFieldIndices.map((i) => i - 1)];
</script>
<Spacer --height="0.5rem" />
<Container --gutter-inline="0.5rem" --gutter-block="0.15rem">
{#each fieldNames as label, idx}
{#if idx === 0}
<MapperRow {label} options={columnNames} bind:index={firstFieldIndex} />
{:else}
<MapperRow {label} {options} bind:index={otherFieldIndices[idx - 1]} />
{/if}
{/each}
</Container>

View file

@ -0,0 +1,21 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import StickyContainer from "../components/StickyContainer.svelte";
export let heading: string;
</script>
<StickyContainer --sticky-border="var(--border)" --sticky-borders="0px 0 1px">
<h1>
{heading}
</h1>
</StickyContainer>
<style lang="scss">
h1 {
padding-top: 0.5em;
}
</style>

View file

@ -0,0 +1,33 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "../components/ButtonGroup.svelte";
import LabelButton from "../components/LabelButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
import * as tr from "../lib/ftl";
import { getPlatformString } from "../lib/shortcuts";
export let onImport: () => void;
function doImport(): void {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
onImport();
}
const keyCombination = "Control+Enter";
</script>
<ButtonGroup>
<LabelButton
theme="primary"
tooltip={getPlatformString(keyCombination)}
on:click={doImport}
--border-left-radius="5px"
--border-right-radius="5px">{tr.actionsImport()}</LabelButton
>
<Shortcut {keyCombination} on:action={doImport} />
</ButtonGroup>

View file

@ -0,0 +1,90 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import Container from "../components/Container.svelte";
import Row from "../components/Row.svelte";
import Switch from "../deck-options/Switch.svelte";
import * as tr from "../lib/ftl";
import { Decks, ImportExport, importExport, Notetypes } from "../lib/proto";
import DeckSelector from "./DeckSelector.svelte";
import DelimiterSelector from "./DelimiterSelector.svelte";
import FieldMapper from "./FieldMapper.svelte";
import Header from "./Header.svelte";
import ImportButton from "./ImportButton.svelte";
import { getCsvMetadata } from "./lib";
import MetaMapper from "./MetaMapper.svelte";
import NotetypeSelector from "./NotetypeSelector.svelte";
export let path: string;
export let notetypeNameIds: Notetypes.NotetypeNameId[];
export let deckNameIds: Decks.DeckNameId[];
export let delimiter: ImportExport.CsvMetadata.Delimiter;
// TODO
export const tags: string = "";
export let columnNames: string[];
export let notetypeId: number;
export let deckId: number;
export let isHtml: boolean;
let fieldColumnIndices: number[];
let tagsColumn: number;
let deckColumn: number;
let notetypeColumn: number;
$: {
getCsvMetadata(path, delimiter).then((meta) => {
columnNames = meta.columns;
});
}
async function onImport(): Promise<void> {
await importExport.importCsv(
ImportExport.ImportCsvRequest.create({
path,
deckId,
notetypeId,
delimiter,
isHtml,
columns: ImportExport.ImportCsvRequest.Columns.create({
fields: fieldColumnIndices,
tags: tagsColumn,
deck: deckColumn,
notetype: notetypeColumn,
}),
columnNames,
}),
);
}
</script>
<div style="--gutter-inline: 0.25rem;">
<Row class="gx-0" --cols={2}>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingImportOptions()} />
<NotetypeSelector {notetypeNameIds} bind:notetypeId />
<DeckSelector {deckNameIds} bind:deckId />
<DelimiterSelector bind:delimiter />
<Switch id={undefined} bind:value={isHtml}>
{tr.importingAllowHtmlInFields()}
</Switch>
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFieldMapping()} />
<FieldMapper {columnNames} {notetypeId} bind:fieldColumnIndices />
<MetaMapper
{columnNames}
bind:tagsColumn
bind:notetypeColumn
bind:deckColumn
/>
</Container>
</Col>
</Row>
<Row><ImportButton {onImport} /></Row>
</div>

View file

@ -0,0 +1,29 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
export let label: string;
export let options: string[];
export let index: number = 0;
const labelIndex = options.indexOf(label);
index = labelIndex > 0 ? labelIndex : index;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{label}
</Col>
<Col --col-size={1}>
<!-- svelte-ignore a11y-no-onchange -->
<select class="form-select" bind:value={index}>
{#each options as name, idx}
<option value={idx}>{name}</option>
{/each}
</select>
</Col>
</Row>

View file

@ -0,0 +1,25 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Container from "../components/Container.svelte";
import Spacer from "../components/Spacer.svelte";
import * as tr from "../lib/ftl";
import MapperRow from "./MapperRow.svelte";
export let columnNames: string[];
export let tagsColumn: number = 0;
export let deckColumn: number = 0;
export let notetypeColumn: number = 0;
$: options = [tr.changeNotetypeNothing()].concat(columnNames);
</script>
<Spacer --height="0.5rem" />
<Container --gutter-inline="0.5rem" --gutter-block="0.15rem">
<MapperRow label={tr.editingTags()} {options} bind:index={tagsColumn} />
<MapperRow label={tr.decksDeck()} {options} bind:index={deckColumn} />
<MapperRow label={tr.notetypesNotetype()} {options} bind:index={notetypeColumn} />
</Container>

View file

@ -0,0 +1,41 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "../components/ButtonGroup.svelte";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import SelectButton from "../components/SelectButton.svelte";
import SelectOption from "../components/SelectOption.svelte";
import * as tr from "../lib/ftl";
import type { Notetypes } from "../lib/proto";
export let notetypeNameIds: Notetypes.NotetypeNameId[];
export let notetypeId: number;
function updateCurrentId(event: Event) {
const index = parseInt((event.target! as HTMLSelectElement).value);
notetypeId = notetypeNameIds[index].id;
}
</script>
<Row --cols={2}>
<Col --col-size={1}>
<div>{tr.notetypesNotetype()}</div>
</Col>
<Col --col-size={1}>
<ButtonGroup class="flex-grow-1">
<SelectButton class="flex-grow-1" on:change={updateCurrentId}>
{#each notetypeNameIds as entry, idx}
<SelectOption
value={String(idx)}
selected={entry.id === notetypeId}
>
{entry.name}
</SelectOption>
{/each}
</SelectButton>
</ButtonGroup>
</Col>
</Row>

View file

@ -0,0 +1,34 @@
@use "sass/vars";
@use "sass/bootstrap-dark";
@import "sass/base";
@import "sass/bootstrap/scss/alert";
@import "sass/bootstrap/scss/buttons";
@import "sass/bootstrap/scss/button-group";
@import "sass/bootstrap/scss/close";
@import "sass/bootstrap/scss/grid";
@import "sass/bootstrap-forms";
.night-mode {
@include bootstrap-dark.night-mode;
}
body {
width: min(100vw, 70em);
margin: 0 auto;
}
html {
overflow-x: hidden;
}
#main {
padding: 0.5em 0.5em 1em 0.5em;
height: 100vh;
}
// override the default down arrow colour in <select> elements
.night-mode select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}

67
ts/import-csv/index.ts Normal file
View file

@ -0,0 +1,67 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./import-csv-base.css";
import { ModuleName, setupI18n } from "../lib/i18n";
import { checkNightMode } from "../lib/nightmode";
import {
Decks,
decks as decksService,
empty,
notetypes as notetypeService,
} from "../lib/proto";
import ImportCsvPage from "./ImportCsvPage.svelte";
import { getCsvMetadata } from "./lib";
const gettingNotetypes = notetypeService.getNotetypeNames(empty);
const gettingDecks = decksService.getDeckNames(
Decks.GetDeckNamesRequest.create({
skipEmptyDefault: false,
includeFiltered: false,
}),
);
const i18n = setupI18n({
modules: [
ModuleName.ACTIONS,
ModuleName.CHANGE_NOTETYPE,
ModuleName.DECKS,
ModuleName.EDITING,
ModuleName.IMPORTING,
ModuleName.NOTETYPES,
ModuleName.STUDYING,
],
});
export async function setupImportCsvPage(path: string): Promise<ImportCsvPage> {
const gettingMetadata = getCsvMetadata(path);
const [notetypes, decks, metadata] = await Promise.all([
gettingNotetypes,
gettingDecks,
gettingMetadata,
i18n,
]);
checkNightMode();
return new ImportCsvPage({
target: document.body,
props: {
path: path,
notetypeNameIds: notetypes.entries,
deckNameIds: decks.entries,
delimiter: metadata.delimiter,
columnNames: metadata.columns,
tags: metadata.tags,
notetypeId: metadata.notetypeId,
deckId: metadata.deckId,
isHtml: metadata.isHtml!,
},
});
}
/* // use #testXXXX where XXXX is notetype ID to test
if (window.location.hash.startsWith("#test")) {
const ntid = parseInt(window.location.hash.substr("#test".length), 10);
setupCsvImportPage(ntid, ntid);
} */

27
ts/import-csv/lib.ts Normal file
View file

@ -0,0 +1,27 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
ImportExport,
importExport,
Notetypes,
notetypes as notetypeService,
} from "../lib/proto";
export async function getNotetypeFields(notetypeId: number): Promise<string[]> {
return notetypeService
.getFieldNames(Notetypes.NotetypeId.create({ ntid: notetypeId }))
.then((list) => list.vals);
}
export async function getCsvMetadata(
path: string,
delimiter?: ImportExport.CsvMetadata.Delimiter,
): Promise<ImportExport.CsvMetadata> {
return importExport.getCsvMetadata(
ImportExport.CsvMetadataRequest.create({
path,
delimiter,
}),
);
}

View file

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"include": ["*"],
"references": [
{ "path": "../lib" },
{ "path": "../sveltelib" },
{ "path": "../components" }
],
"compilerOptions": {
"types": ["jest"]
}
}

View file

@ -15,6 +15,7 @@ import DeckConfig = anki.deckconfig;
import Decks = anki.decks; import Decks = anki.decks;
import Generic = anki.generic; import Generic = anki.generic;
import I18n = anki.i18n; import I18n = anki.i18n;
import ImportExport = anki.import_export;
import Notes = anki.notes; import Notes = anki.notes;
import Notetypes = anki.notetypes; import Notetypes = anki.notetypes;
import Scheduler = anki.scheduler; import Scheduler = anki.scheduler;
@ -54,6 +55,8 @@ async function serviceCallback(
} }
} }
export const decks = Decks.DecksService.create(serviceCallback as RPCImpl);
export { DeckConfig }; export { DeckConfig };
export const deckConfig = DeckConfig.DeckConfigService.create( export const deckConfig = DeckConfig.DeckConfigService.create(
serviceCallback as RPCImpl, serviceCallback as RPCImpl,
@ -62,6 +65,11 @@ export const deckConfig = DeckConfig.DeckConfigService.create(
export { I18n }; export { I18n };
export const i18n = I18n.I18nService.create(serviceCallback as RPCImpl); export const i18n = I18n.I18nService.create(serviceCallback as RPCImpl);
export { ImportExport };
export const importExport = ImportExport.ImportExportService.create(
serviceCallback as RPCImpl,
);
export { Notetypes }; export { Notetypes };
export const notetypes = Notetypes.NotetypesService.create(serviceCallback as RPCImpl); export const notetypes = Notetypes.NotetypesService.create(serviceCallback as RPCImpl);