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-comma = Comma
importing-empty-first-field = Empty first field: { $val }
importing-field-delimiter = Field delimiter
importing-field-mapping = Field mapping
importing-field-of-file-is = Field <b>{ $val }</b> of file is:
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-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-pipe = Pipe
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-semicolon = Semicolon

View file

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

View file

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

View file

@ -34,7 +34,7 @@ BrowserRow = search_pb2.BrowserRow
BrowserColumns = search_pb2.BrowserColumns
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
ImportLogWithChanges = import_export_pb2.ImportResponse
CsvColumn = import_export_pb2.ImportCsvRequest.CsvColumn
ImportCsvRequest = import_export_pb2.ImportCsvRequest
CsvMetadata = import_export_pb2.CsvMetadata
Delimiter = import_export_pb2.CsvMetadata.Delimiter
@ -410,23 +410,9 @@ class Collection(DeprecatedNamesMixin):
request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter)
return self._backend.get_csv_metadata(request)
def import_csv(
self,
path: str,
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_csv(self, request: ImportCsvRequest) -> ImportLogWithChanges:
log = self._backend.import_csv_raw(request.SerializeToString())
return ImportLogWithChanges.FromString(log)
def import_json_file(self, path: str) -> ImportLogWithChanges:
return self._backend.import_json_file(path)

View file

@ -7,6 +7,7 @@ _pages = [
"change-notetype",
"card-info",
"fields",
"import-csv",
]
[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
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.foreign_data import mnemosyne
from anki.lang import without_unicode_isolation
from aqt.import_export.import_csv_dialog import ImportCsvDialog
from aqt.operations import CollectionOp, QueryOp
from aqt.progress import ProgressUpdate
from aqt.qt import *
@ -105,9 +106,15 @@ class CsvImporter(Importer):
@staticmethod
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):

View file

@ -31,6 +31,7 @@ from anki.scheduler.v3 import NextStates
from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog
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.qt import *
@ -438,6 +439,18 @@ def change_notetype() -> bytes:
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 = [
congrats_info,
get_deck_configs_for_update,
@ -445,13 +458,19 @@ post_handler_list = [
next_card_states,
set_next_card_states,
change_notetype,
import_csv,
]
exposed_backend_list = [
# DeckService
"get_deck_names",
# I18nService
"i18n_resources",
# ImportExportService
"get_csv_metadata",
# NotesService
"get_field_names",
"get_note",
# NotetypesService
"get_notetype_names",

View file

@ -168,9 +168,15 @@ impl NotetypesService for Backend {
.map(Into::into)
})
}
fn change_notetype(&self, input: pb::ChangeNotetypeRequest) -> Result<pb::OpChanges> {
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 {

View file

@ -130,9 +130,11 @@ impl Collection {
fn column_label(&self, idx: usize, column: &str) -> String {
match column.trim() {
"" => self.tr.importing_column(idx + 1).to_string(),
"tags" => self.tr.editing_tags().to_string(),
s => s.to_string(),
"" => self.tr.importing_column(idx + 1).into(),
"tags" => self.tr.editing_tags().into(),
"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])?;
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 Generic = anki.generic;
import I18n = anki.i18n;
import ImportExport = anki.import_export;
import Notes = anki.notes;
import Notetypes = anki.notetypes;
import Scheduler = anki.scheduler;
@ -54,6 +55,8 @@ async function serviceCallback(
}
}
export const decks = Decks.DecksService.create(serviceCallback as RPCImpl);
export { DeckConfig };
export const deckConfig = DeckConfig.DeckConfigService.create(
serviceCallback as RPCImpl,
@ -62,6 +65,11 @@ export const deckConfig = DeckConfig.DeckConfigService.create(
export { I18n };
export const i18n = I18n.I18nService.create(serviceCallback as RPCImpl);
export { ImportExport };
export const importExport = ImportExport.ImportExportService.create(
serviceCallback as RPCImpl,
);
export { Notetypes };
export const notetypes = Notetypes.NotetypesService.create(serviceCallback as RPCImpl);