Tooltips for CSV import and import page refactoring (#2655)

* Make enum selector generic

* Refactor ImportCsvPage to support tooltips

* Improve csv import defaults

* Unify import pages

* Improve import page styling

* Fix life cycle issue with import properties

* Remove size constraints to fix scrollbar styling

* Add help strings and urls to csv import page

* Show ErrorPage on ImportPage error

* Fix escaping of import path

* Unify ImportPage and ImportLogPage

* Apply suggestions from code review (dae)

* Fix import progress

* Fix preview overflowing container

* Don't include <br> in FileIoErrors (dae)

e.g. 500: Failed to read '/home/dae/foo2.csv':<br>stream did not contain valid UTF-8

I thought about using {@html ...} here, but that's a potential security issue,
as the filename is not something we control.
This commit is contained in:
RumovZ 2023-09-14 01:06:15 +02:00 committed by GitHub
parent 23823d3135
commit 850043b49b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1055 additions and 941 deletions

View file

@ -351,7 +351,7 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
],
)?;
build_page(
"import-log",
"import-page",
true,
inputs![
//

View file

@ -188,6 +188,36 @@ importing-note-updated-as-file-had-newer = Note updated, as file had newer versi
importing-note-skipped-due-to-missing-notetype = Note skipped, as its notetype was missing
importing-note-skipped-due-to-missing-deck = Note skipped, as its deck was missing
importing-note-skipped-due-to-empty-first-field = Note skipped, as its first field is empty
importing-field-separator-help =
The character separating fields in the text file. You can use the preview to check
if the fields are separated correctly.
Please note that if this character appears in any field itself, the field has to be
quoted accordingly to the CSV standard. Spreadsheet programs like LibreOffice will
do this automatically.
importing-allow-html-in-fields-help =
Enable this if the file contains HTML formatting. E.g. if the file contains the string
'&lt;br&gt;', it will appear as a line break on your card. On the other hand, with this
option disabled, the literal characters '&lt;br&gt;' will be rendered.
importing-notetype-help =
Newly-imported notes will have this notetype, and only existing notes with this
notetype will be updated.
You can choose which fields in the file correspond to which notetype fields with the
mapping tool.
importing-deck-help = Imported cards will be placed in this deck.
importing-existing-notes-help =
What to do if an imported note matches an existing one.
- `{ importing-update }`: Update the existing note.
- `{ importing-preserve }`: Do nothing.
- `{ importing-duplicate }`: Create a new note.
importing-match-scope-help =
Only existing notes with the same notetype will be checked for duplicates. This can
additionally be restricted to notes with cards in the same deck.
importing-tag-all-notes-help =
These tags will be added to both newly-imported and updated notes.
importing-tag-updated-notes-help = These tags will be added to any updated notes.
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass
import aqt
import aqt.deckconf
@ -14,45 +15,76 @@ from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGe
from aqt.webview import AnkiWebView, AnkiWebViewKind
@dataclass
class ImportArgs:
path: str
title = "importLog"
kind = AnkiWebViewKind.IMPORT_LOG
ts_page = "import-page"
setup_function_name = "setupImportPage"
def args_json(self) -> str:
return json.dumps(self.path)
class JsonFileArgs(ImportArgs):
def args_json(self) -> str:
return json.dumps(dict(type="json_file", path=self.path))
@dataclass
class JsonStringArgs(ImportArgs):
json: str
def args_json(self) -> str:
return json.dumps(dict(type="json_string", path=self.path, json=self.json))
class CsvArgs(ImportArgs):
title = "csv import"
kind = AnkiWebViewKind.IMPORT_CSV
ts_page = "import-csv"
setup_function_name = "setupImportCsvPage"
class AnkiPackageArgs(ImportArgs):
title = "anki package import"
kind = AnkiWebViewKind.IMPORT_ANKI_PACKAGE
ts_page = "import-anki-package"
setup_function_name = "setupImportAnkiPackagePage"
class ImportDialog(QDialog):
TITLE: str
KIND: AnkiWebViewKind
TS_PAGE: str
SETUP_FUNCTION_NAME: str
DEFAULT_SIZE = (800, 800)
MIN_SIZE = (400, 300)
silentlyClose = True
def __init__(
self,
mw: aqt.main.AnkiQt,
path: str,
) -> None:
def __init__(self, mw: aqt.main.AnkiQt, args: ImportArgs) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
self._setup_ui(path)
self.args = args
self._setup_ui()
self.show()
def _setup_ui(self, path: str) -> None:
def _setup_ui(self) -> None:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.mw.garbage_collect_on_dialog_finish(self)
self.setMinimumSize(*self.MIN_SIZE)
disable_help_button(self)
restoreGeom(self, self.TITLE, default_size=self.DEFAULT_SIZE)
restoreGeom(self, self.args.title, default_size=self.DEFAULT_SIZE)
addCloseShortcut(self)
self.web = AnkiWebView(kind=self.KIND)
self.web = AnkiWebView(kind=self.args.kind)
self.web.setVisible(False)
self.web.load_ts_page(self.TS_PAGE)
self.web.load_ts_page(self.args.ts_page)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.web)
self.setLayout(layout)
restoreGeom(self, self.TITLE, default_size=(800, 800))
restoreGeom(self, self.args.title, default_size=(800, 800))
escaped_path = json.dumps(path.replace("'", r"\'"))
self.web.evalWithCallback(
f"anki.{self.SETUP_FUNCTION_NAME}({escaped_path});",
f"anki.{self.args.setup_function_name}({self.args.args_json()});",
lambda _: self.web.setFocus(),
)
self.setWindowTitle(tr.decks_import_file())
@ -62,19 +94,5 @@ class ImportDialog(QDialog):
self.mw.col.set_wants_abort()
self.web.cleanup()
self.web = None
saveGeom(self, self.TITLE)
saveGeom(self, self.args.title)
QDialog.reject(self)
class ImportCsvDialog(ImportDialog):
TITLE = "csv import"
KIND = AnkiWebViewKind.IMPORT_CSV
TS_PAGE = "import-csv"
SETUP_FUNCTION_NAME = "setupImportCsvPage"
class ImportAnkiPackageDialog(ImportDialog):
TITLE = "anki package import"
KIND = AnkiWebViewKind.IMPORT_ANKI_PACKAGE
TS_PAGE = "import-anki-package"
SETUP_FUNCTION_NAME = "setupImportAnkiPackagePage"

View file

@ -1,87 +0,0 @@
# 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 dataclasses
import json
from dataclasses import dataclass
import aqt
import aqt.deckconf
import aqt.main
import aqt.operations
from aqt.qt import *
from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr
from aqt.webview import AnkiWebView, AnkiWebViewKind
@dataclass
class _CommonArgs:
type: str = dataclasses.field(init=False)
path: str
def to_json(self) -> str:
return json.dumps(dataclasses.asdict(self))
@dataclass
class JsonFileArgs(_CommonArgs):
type = "json_file"
@dataclass
class JsonStringArgs(_CommonArgs):
type = "json_string"
json: str
class ImportLogDialog(QDialog):
GEOMETRY_KEY = "importLog"
silentlyClose = True
def __init__(
self,
mw: aqt.main.AnkiQt,
args: JsonFileArgs | JsonStringArgs,
) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
self._setup_ui(args)
self.show()
def _setup_ui(
self,
args: JsonFileArgs | JsonStringArgs,
) -> None:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.mw.garbage_collect_on_dialog_finish(self)
self.setMinimumSize(400, 300)
disable_help_button(self)
addCloseShortcut(self)
self.web = AnkiWebView(kind=AnkiWebViewKind.IMPORT_LOG)
self.web.setVisible(False)
self.web.load_ts_page("import-log")
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.web)
self.setLayout(layout)
restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800))
self.web.evalWithCallback(
"anki.setupImportLogPage(%s);" % (args.to_json()),
lambda _: self.web.setFocus(),
)
title = tr.importing_import_log()
title += f" - {os.path.basename(args.path)}"
self.setWindowTitle(title)
def reject(self) -> None:
if self.mw.col and self.windowModality() == Qt.WindowModality.ApplicationModal:
self.mw.col.set_wants_abort()
self.web.cleanup()
self.web = None
saveGeom(self, self.GEOMETRY_KEY)
QDialog.reject(self)

View file

@ -13,9 +13,10 @@ from anki.collection import Collection, Progress
from anki.errors import Interrupted
from anki.foreign_data import mnemosyne
from anki.lang import without_unicode_isolation
from aqt.import_export.import_dialog import ImportAnkiPackageDialog, ImportCsvDialog
from aqt.import_export.import_log_dialog import (
ImportLogDialog,
from aqt.import_export.import_dialog import (
AnkiPackageArgs,
CsvArgs,
ImportDialog,
JsonFileArgs,
JsonStringArgs,
)
@ -87,7 +88,7 @@ class ApkgImporter(Importer):
@staticmethod
def do_import(mw: aqt.main.AnkiQt, path: str) -> None:
ImportAnkiPackageDialog(mw, path)
ImportDialog(mw, AnkiPackageArgs(path))
class MnemosyneImporter(Importer):
@ -98,9 +99,7 @@ class MnemosyneImporter(Importer):
QueryOp(
parent=mw,
op=lambda col: mnemosyne.serialize(path, col.decks.current()["id"]),
success=lambda json: ImportLogDialog(
mw, JsonStringArgs(path=path, json=json)
),
success=lambda json: ImportDialog(mw, JsonStringArgs(path=path, json=json)),
).with_progress().run_in_background()
@ -109,7 +108,7 @@ class CsvImporter(Importer):
@staticmethod
def do_import(mw: aqt.main.AnkiQt, path: str) -> None:
ImportCsvDialog(mw, path)
ImportDialog(mw, CsvArgs(path))
class JsonImporter(Importer):
@ -117,7 +116,7 @@ class JsonImporter(Importer):
@staticmethod
def do_import(mw: aqt.main.AnkiQt, path: str) -> None:
ImportLogDialog(mw, JsonFileArgs(path=path))
ImportDialog(mw, JsonFileArgs(path=path))
IMPORTERS: list[Type[Importer]] = [

View file

@ -425,9 +425,8 @@ def import_done() -> bytes:
def update_window_modality() -> None:
if window := aqt.mw.app.activeWindow():
from aqt.import_export.import_dialog import ImportDialog
from aqt.import_export.import_log_dialog import ImportLogDialog
if isinstance(window, (ImportDialog, ImportLogDialog)):
if isinstance(window, ImportDialog):
window.hide()
window.setWindowModality(Qt.WindowModality.NonModal)
window.show()

View file

@ -48,7 +48,7 @@ impl FileOp {
impl FileIoError {
pub fn message(&self) -> String {
format!(
"Failed to {} '{}':<br>{}",
"Failed to {} '{}': {}",
match &self.op {
FileOp::Unknown => return format!("{}", self.source),
FileOp::Open => "open".into(),

View file

@ -11,7 +11,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
type ResultWithChanges = OpChanges | { changes?: OpChanges };
export let task: () => Promise<ResultWithChanges | undefined>;
export let result: ResultWithChanges | undefined = undefined;
export let result: ResultWithChanges | undefined;
export let error: Error | undefined;
let label: string = "";
function onUpdate(progress: Progress) {
@ -24,8 +25,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
$: (async () => {
if (!result) {
result = await runWithBackendProgress(task, onUpdate);
if (!result && !error) {
try {
result = await runWithBackendProgress(task, onUpdate);
} catch (err) {
if (err instanceof Error) {
error = err;
} else {
throw err;
}
}
}
})();
</script>

View file

@ -2,21 +2,34 @@
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
export interface Choice<T> {
label: string;
value: T;
}
</script>
<script lang="ts">
import Select from "./Select.svelte";
import SelectOption from "./SelectOption.svelte";
export let options: string[] = [];
export let disabled: number[] = [];
export let value = 0;
type T = $$Generic;
$: label = options[value];
export let value: T;
export let choices: Choice<T>[] = [];
export let disabled: boolean = false;
export let disabledChoices: T[] = [];
$: label = choices.find((c) => c.value === value)?.label;
</script>
<Select bind:value {label}>
{#each options as option, idx}
<SelectOption value={idx} disabled={disabled.includes(idx)}>
{option}
<Select bind:value {label} {disabled}>
{#each choices as { label: optionLabel, value: optionValue }}
<SelectOption
value={optionValue}
disabled={disabledChoices.includes(optionValue)}
>
{optionLabel}
</SelectOption>
{/each}
</Select>

View file

@ -5,16 +5,19 @@
<script lang="ts">
import Col from "./Col.svelte";
import ConfigInput from "./ConfigInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
import EnumSelector, { type Choice } from "./EnumSelector.svelte";
import RevertButton from "./RevertButton.svelte";
import Row from "./Row.svelte";
import type { Breakpoint } from "./types";
export let value: number;
export let defaultValue: number;
type T = $$Generic;
export let value: T;
export let defaultValue: T;
export let breakpoint: Breakpoint = "md";
export let choices: string[];
export let disabled: number[] = [];
export let choices: Choice<T>[];
export let disabled: boolean = false;
export let disabledChoices: T[] = [];
</script>
<Row --cols={13}>
@ -23,7 +26,7 @@
</Col>
<Col --col-size={6} {breakpoint}>
<ConfigInput>
<EnumSelector bind:value options={choices} {disabled} />
<EnumSelector bind:value {choices} {disabled} {disabledChoices} />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Col>

View file

@ -12,6 +12,7 @@
export let value: boolean;
export let defaultValue: boolean;
export let disabled: boolean = false;
const id = Math.random().toString(36).substring(2);
</script>
@ -20,7 +21,7 @@
<Col --col-size={4}><Label for={id} preventMouseClick><slot /></Label></Col>
<Col --col-justify="flex-end">
<ConfigInput grow={false}>
<Switch {id} bind:value />
<Switch {id} bind:value {disabled} />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Col>

View file

@ -0,0 +1,36 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { writable } from "svelte/store";
import TagEditor from "../tag-editor/TagEditor.svelte";
import Col from "./Col.svelte";
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
import Row from "./Row.svelte";
import type { Breakpoint } from "./types";
export let tags: string[];
export let keyCombination: string | undefined = undefined;
export let breakpoint: Breakpoint = "md";
const tagsWritable = writable<string[]>(tags);
</script>
<Row --cols={13}>
<Col --col-size={7} {breakpoint}>
<slot />
</Col>
<Col --col-size={6} {breakpoint}>
<ConfigInput>
<TagEditor
tags={tagsWritable}
on:tagsupdate={({ detail }) => (tags = detail.tags)}
{keyCombination}
/>
<RevertButton slot="revert" bind:value={$tagsWritable} defaultValue={[]} />
</ConfigInput>
</Col>
</Row>

View file

@ -19,8 +19,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import {
newGatherPriorityChoices,
newSortOrderChoices,
reviewMixChoices,
reviewOrderChoices,
} from "./choices";
import type { DeckOptionsState } from "./lib";
import { reviewMixChoices } from "./strings";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -30,32 +35,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const currentDeck = "\n\n" + tr.deckConfigDisplayOrderWillUseCurrentDeck();
const newGatherPriorityChoices = [
tr.deckConfigNewGatherPriorityDeck(),
tr.deckConfigNewGatherPriorityPositionLowestFirst(),
tr.deckConfigNewGatherPriorityPositionHighestFirst(),
tr.deckConfigNewGatherPriorityRandomNotes(),
tr.deckConfigNewGatherPriorityRandomCards(),
];
const newSortOrderChoices = [
tr.deckConfigSortOrderTemplateThenGather(),
tr.deckConfigSortOrderGather(),
tr.deckConfigSortOrderCardTemplateThenRandom(),
tr.deckConfigSortOrderRandomNoteThenTemplate(),
tr.deckConfigSortOrderRandom(),
];
const reviewOrderChoices = [
tr.deckConfigSortOrderDueDateThenRandom(),
tr.deckConfigSortOrderDueDateThenDeck(),
tr.deckConfigSortOrderDeckThenDueDate(),
tr.deckConfigSortOrderAscendingIntervals(),
tr.deckConfigSortOrderDescendingIntervals(),
tr.deckConfigSortOrderAscendingEase(),
tr.deckConfigSortOrderDescendingEase(),
tr.deckConfigSortOrderRelativeOverdueness(),
tr.deckConfigSortOrderRandom(),
];
let disabledNewSortOrders: number[] = [];
$: {
switch ($config.newCardGatherPriority) {
@ -143,7 +122,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<EnumSelectorRow
bind:value={$config.newCardGatherPriority}
defaultValue={defaults.newCardGatherPriority}
choices={newGatherPriorityChoices}
choices={newGatherPriorityChoices()}
>
<SettingTitle
on:click={() =>
@ -160,8 +139,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<EnumSelectorRow
bind:value={$config.newCardSortOrder}
defaultValue={defaults.newCardSortOrder}
choices={newSortOrderChoices}
disabled={disabledNewSortOrders}
choices={newSortOrderChoices()}
disabledChoices={disabledNewSortOrders}
>
<SettingTitle
on:click={() =>
@ -212,7 +191,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<EnumSelectorRow
bind:value={$config.reviewOrder}
defaultValue={defaults.reviewOrder}
choices={reviewOrderChoices}
choices={reviewOrderChoices()}
>
<SettingTitle
on:click={() =>

View file

@ -15,6 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import { leechChoices } from "./choices";
import type { DeckOptionsState } from "./lib";
import SpinBoxRow from "./SpinBoxRow.svelte";
import StepsInputRow from "./StepsInputRow.svelte";
@ -37,8 +38,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
: "";
}
const leechChoices = [tr.actionsSuspendCard(), tr.schedulingTagOnly()];
const settings = {
relearningSteps: {
title: tr.deckConfigRelearningSteps(),
@ -136,7 +135,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<EnumSelectorRow
bind:value={$config.leechAction}
defaultValue={defaults.leechAction}
choices={leechChoices}
choices={leechChoices()}
breakpoint="md"
>
<SettingTitle

View file

@ -16,6 +16,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import { newInsertOrderChoices } from "./choices";
import type { DeckOptionsState } from "./lib";
import SpinBoxRow from "./SpinBoxRow.svelte";
import StepsInputRow from "./StepsInputRow.svelte";
@ -27,11 +28,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const config = state.currentConfig;
const defaults = state.defaults;
const newInsertOrderChoices = [
tr.deckConfigNewInsertionOrderSequential(),
tr.deckConfigNewInsertionOrderRandom(),
];
let stepsExceedGraduatingInterval: string;
$: {
const lastLearnStepInDays = $config.learnSteps.length
@ -155,7 +151,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<EnumSelectorRow
bind:value={$config.newCardInsertOrder}
defaultValue={defaults.newCardInsertOrder}
choices={newInsertOrderChoices}
choices={newInsertOrderChoices()}
breakpoint={"md"}
>
<SettingTitle

147
ts/deck-options/choices.ts Normal file
View file

@ -0,0 +1,147 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
DeckConfig_Config_LeechAction,
DeckConfig_Config_NewCardGatherPriority,
DeckConfig_Config_NewCardInsertOrder,
DeckConfig_Config_NewCardSortOrder,
DeckConfig_Config_ReviewCardOrder,
DeckConfig_Config_ReviewMix,
} from "@tslib/anki/deck_config_pb";
import * as tr from "@tslib/ftl";
import type { Choice } from "components/EnumSelector.svelte";
export function newGatherPriorityChoices(): Choice<DeckConfig_Config_NewCardGatherPriority>[] {
return [
{
label: tr.deckConfigNewGatherPriorityDeck(),
value: DeckConfig_Config_NewCardGatherPriority.DECK,
},
{
label: tr.deckConfigNewGatherPriorityPositionLowestFirst(),
value: DeckConfig_Config_NewCardGatherPriority.LOWEST_POSITION,
},
{
label: tr.deckConfigNewGatherPriorityPositionHighestFirst(),
value: DeckConfig_Config_NewCardGatherPriority.HIGHEST_POSITION,
},
{
label: tr.deckConfigNewGatherPriorityRandomNotes(),
value: DeckConfig_Config_NewCardGatherPriority.RANDOM_NOTES,
},
{
label: tr.deckConfigNewGatherPriorityRandomCards(),
value: DeckConfig_Config_NewCardGatherPriority.RANDOM_CARDS,
},
];
}
export function newSortOrderChoices(): Choice<DeckConfig_Config_NewCardSortOrder>[] {
return [
{
label: tr.deckConfigSortOrderTemplateThenGather(),
value: DeckConfig_Config_NewCardSortOrder.TEMPLATE,
},
{
label: tr.deckConfigSortOrderGather(),
value: DeckConfig_Config_NewCardSortOrder.NO_SORT,
},
{
label: tr.deckConfigSortOrderCardTemplateThenRandom(),
value: DeckConfig_Config_NewCardSortOrder.TEMPLATE_THEN_RANDOM,
},
{
label: tr.deckConfigSortOrderRandomNoteThenTemplate(),
value: DeckConfig_Config_NewCardSortOrder.RANDOM_NOTE_THEN_TEMPLATE,
},
{
label: tr.deckConfigSortOrderRandom(),
value: DeckConfig_Config_NewCardSortOrder.RANDOM_CARD,
},
];
}
export function reviewOrderChoices(): Choice<DeckConfig_Config_ReviewCardOrder>[] {
return [
{
label: tr.deckConfigSortOrderDueDateThenRandom(),
value: DeckConfig_Config_ReviewCardOrder.DAY,
},
{
label: tr.deckConfigSortOrderDueDateThenDeck(),
value: DeckConfig_Config_ReviewCardOrder.DAY_THEN_DECK,
},
{
label: tr.deckConfigSortOrderDeckThenDueDate(),
value: DeckConfig_Config_ReviewCardOrder.DECK_THEN_DAY,
},
{
label: tr.deckConfigSortOrderAscendingIntervals(),
value: DeckConfig_Config_ReviewCardOrder.INTERVALS_ASCENDING,
},
{
label: tr.deckConfigSortOrderDescendingIntervals(),
value: DeckConfig_Config_ReviewCardOrder.INTERVALS_DESCENDING,
},
{
label: tr.deckConfigSortOrderAscendingEase(),
value: DeckConfig_Config_ReviewCardOrder.EASE_ASCENDING,
},
{
label: tr.deckConfigSortOrderDescendingEase(),
value: DeckConfig_Config_ReviewCardOrder.EASE_DESCENDING,
},
{
label: tr.deckConfigSortOrderRelativeOverdueness(),
value: DeckConfig_Config_ReviewCardOrder.RELATIVE_OVERDUENESS,
},
{
label: tr.deckConfigSortOrderRandom(),
value: DeckConfig_Config_ReviewCardOrder.RANDOM,
},
];
}
export function reviewMixChoices(): Choice<DeckConfig_Config_ReviewMix>[] {
return [
{
label: tr.deckConfigReviewMixMixWithReviews(),
value: DeckConfig_Config_ReviewMix.MIX_WITH_REVIEWS,
},
{
label: tr.deckConfigReviewMixShowAfterReviews(),
value: DeckConfig_Config_ReviewMix.AFTER_REVIEWS,
},
{
label: tr.deckConfigReviewMixShowBeforeReviews(),
value: DeckConfig_Config_ReviewMix.BEFORE_REVIEWS,
},
];
}
export function leechChoices(): Choice<DeckConfig_Config_LeechAction>[] {
return [
{
label: tr.actionsSuspendCard(),
value: DeckConfig_Config_LeechAction.SUSPEND,
},
{
label: tr.schedulingTagOnly(),
value: DeckConfig_Config_LeechAction.TAG_ONLY,
},
];
}
export function newInsertOrderChoices(): Choice<DeckConfig_Config_NewCardInsertOrder>[] {
return [
{
label: tr.deckConfigNewInsertionOrderSequential(),
value: DeckConfig_Config_NewCardInsertOrder.DUE,
},
{
label: tr.deckConfigNewInsertionOrderRandom(),
value: DeckConfig_Config_NewCardInsertOrder.RANDOM,
},
];
}

View file

@ -1,10 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "@tslib/ftl";
export const reviewMixChoices = (): string[] => [
tr.deckConfigReviewMixMixWithReviews(),
tr.deckConfigReviewMixShowAfterReviews(),
tr.deckConfigReviewMixShowBeforeReviews(),
];

View file

@ -3,41 +3,26 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type {
ImportAnkiPackageOptions,
ImportResponse,
} from "@tslib/anki/import_export_pb";
import type { ImportAnkiPackageOptions } from "@tslib/anki/import_export_pb";
import { importAnkiPackage } from "@tslib/backend";
import { importDone } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal";
import BackendProgressIndicator from "components/BackendProgressIndicator.svelte";
import Container from "components/Container.svelte";
import EnumSelectorRow from "components/EnumSelectorRow.svelte";
import Row from "components/Row.svelte";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Row from "../components/Row.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import StickyHeader from "../components/StickyHeader.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import ImportLogPage from "../import-log/ImportLogPage.svelte";
import ImportPage from "../import-page/ImportPage.svelte";
import { updateChoices } from "./choices";
export let path: string;
export let options: ImportAnkiPackageOptions;
let importResponse: ImportResponse | undefined = undefined;
let importing = false;
const updateChoices = [
tr.importingUpdateIfNewer(),
tr.importingUpdateAlways(),
tr.importingUpdateNever(),
];
const settings = {
mergeNotetypes: {
title: tr.importingMergeNotetypes(),
@ -64,106 +49,75 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let modal: Modal;
let carousel: Carousel;
async function onImport(): Promise<ImportResponse> {
const result = await importAnkiPackage({
packagePath: path,
options,
});
await importDone({});
importing = false;
return result;
}
function openHelpModal(index: number): void {
modal.show();
carousel.to(index);
}
</script>
{#if importing}
<BackendProgressIndicator task={onImport} bind:result={importResponse} />
{:else if importResponse}
<ImportLogPage response={importResponse} params={{ path }} />
{:else}
<StickyHeader {path} onImport={() => (importing = true)} />
<ImportPage
{path}
importer={{
doImport: () =>
importAnkiPackage({ packagePath: path, options }, { alertOnError: false }),
}}
>
<Row class="d-block">
<TitledContainer title={tr.importingImportOptions()}>
<HelpModal
title={tr.importingImportOptions()}
url={HelpPage.PackageImporting.root}
slot="tooltip"
{helpSections}
on:mount={(e) => {
modal = e.detail.modal;
carousel = e.detail.carousel;
}}
/>
<Container
breakpoint="sm"
--gutter-inline="0.25rem"
--gutter-block="0.75rem"
class="container-columns"
>
<Row class="d-block">
<TitledContainer title={tr.importingImportOptions()}>
<HelpModal
title={tr.importingImportOptions()}
url={HelpPage.PackageImporting.root}
slot="tooltip"
{helpSections}
on:mount={(e) => {
modal = e.detail.modal;
carousel = e.detail.carousel;
}}
/>
<SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("mergeNotetypes"),
)}
>
{settings.mergeNotetypes.title}
</SettingTitle>
</SwitchRow>
<EnumSelectorRow
bind:value={options.updateNotes}
defaultValue={0}
choices={updateChoices}
<SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("mergeNotetypes"))}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("updateNotes"))}
>
{settings.updateNotes.title}
</SettingTitle>
</EnumSelectorRow>
{settings.mergeNotetypes.title}
</SettingTitle>
</SwitchRow>
<EnumSelectorRow
bind:value={options.updateNotetypes}
defaultValue={0}
choices={updateChoices}
<EnumSelectorRow
bind:value={options.updateNotes}
defaultValue={0}
choices={updateChoices()}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("updateNotes"))}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("updateNotetypes"),
)}
>
{settings.updateNotetypes.title}
</SettingTitle>
</EnumSelectorRow>
{settings.updateNotes.title}
</SettingTitle>
</EnumSelectorRow>
<SwitchRow bind:value={options.withScheduling} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("withScheduling"),
)}
>
{settings.withScheduling.title}
</SettingTitle>
</SwitchRow>
</TitledContainer>
</Row>
</Container>
{/if}
<EnumSelectorRow
bind:value={options.updateNotetypes}
defaultValue={0}
choices={updateChoices()}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("updateNotetypes"))}
>
{settings.updateNotetypes.title}
</SettingTitle>
</EnumSelectorRow>
<style lang="scss">
:global(.row) {
// rows have negative margins by default
--bs-gutter-x: 0;
margin-bottom: 0.5rem;
}
</style>
<SwitchRow bind:value={options.withScheduling} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("withScheduling"))}
>
{settings.withScheduling.title}
</SettingTitle>
</SwitchRow>
</TitledContainer>
</Row>
</ImportPage>

View file

@ -0,0 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { ImportAnkiPackageUpdateCondition } from "@tslib/anki/import_export_pb";
import * as tr from "@tslib/ftl";
import type { Choice } from "components/EnumSelector.svelte";
export function updateChoices(): Choice<ImportAnkiPackageUpdateCondition>[] {
return [
{
label: tr.importingUpdateIfNewer(),
value: ImportAnkiPackageUpdateCondition.IF_NEWER,
},
{
label: tr.importingUpdateAlways(),
value: ImportAnkiPackageUpdateCondition.ALWAYS,
},
{
label: tr.importingUpdateNever(),
value: ImportAnkiPackageUpdateCondition.NEVER,
},
];
}

View file

@ -18,9 +18,6 @@
}
body {
min-height: 100vh;
width: min(100vw, 70em);
margin: 0 auto;
padding: 0 1em 1em 1em;
}

View file

@ -1,41 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { CsvMetadata_MatchScope } from "@tslib/anki/import_export_pb";
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
export let matchScope: CsvMetadata_MatchScope;
const matchScopes = [
{
value: CsvMetadata_MatchScope.NOTETYPE,
label: tr.notetypesNotetype(),
},
{
value: CsvMetadata_MatchScope.NOTETYPE_AND_DECK,
label: tr.importingNotetypeAndDeck(),
},
];
$: label = matchScopes.find((r) => r.value === matchScope)?.label;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.importingMatchScope()}
</Col>
<Col --col-size={1}>
<Select bind:value={matchScope} {label}>
{#each matchScopes as { label, value }}
<SelectOption {value}>{label}</SelectOption>
{/each}
</Select>
</Col>
</Row>

View file

@ -1,31 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { DeckNameId } from "@tslib/anki/decks_pb";
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
export let deckNameIds: DeckNameId[];
export let deckId: bigint;
$: label = deckNameIds.find((d) => d.id === deckId)?.name.replace(/^.+::/, "...");
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.decksDeck()}
</Col>
<Col --col-size={1}>
<Select bind:value={deckId} {label}>
{#each deckNameIds as { id, name }}
<SelectOption value={id}>{name}</SelectOption>
{/each}
</Select>
</Col>
</Row>

View file

@ -1,40 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { CsvMetadata_Delimiter as Delimiter } from "@tslib/anki/import_export_pb";
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
export let delimiter: Delimiter;
export let disabled: boolean;
const delimiters = [
{ value: Delimiter.TAB, label: tr.importingTab() },
{ value: Delimiter.PIPE, label: tr.importingPipe() },
{ value: Delimiter.SEMICOLON, label: tr.importingSemicolon() },
{ value: Delimiter.COLON, label: tr.importingColon() },
{ value: Delimiter.COMMA, label: tr.importingComma() },
{ value: Delimiter.SPACE, label: tr.studyingSpace() },
];
$: label = delimiters.find((d) => d.value === delimiter)?.label;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.importingFieldSeparator()}
</Col>
<Col --col-size={1}>
<Select bind:value={delimiter} {disabled} {label}>
{#each delimiters as { value, label }}
<SelectOption {value}>{label}</SelectOption>
{/each}
</Select>
</Col>
</Row>

View file

@ -1,45 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { CsvMetadata_DupeResolution as DupeResolution } from "@tslib/anki/import_export_pb";
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
export let dupeResolution: DupeResolution;
const dupeResolutions = [
{
value: DupeResolution.UPDATE,
label: tr.importingUpdate(),
},
{
value: DupeResolution.DUPLICATE,
label: tr.importingDuplicate(),
},
{
value: DupeResolution.PRESERVE,
label: tr.importingPreserve(),
},
];
$: label = dupeResolutions.find((r) => r.value === dupeResolution)?.label;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.importingExistingNotes()}
</Col>
<Col --col-size={1}>
<Select bind:value={dupeResolution} {label}>
{#each dupeResolutions as { label, value }}
<SelectOption {value}>{label}</SelectOption>
{/each}
</Select>
</Col>
</Row>

View file

@ -3,41 +3,36 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { CsvMetadata_MappedNotetype } from "@tslib/anki/import_export_pb";
import { getFieldNames } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import TitledContainer from "components/TitledContainer.svelte";
import Spacer from "../components/Spacer.svelte";
import type { ColumnOption } from "./lib";
import type { ImportCsvState } from "./lib";
import MapperRow from "./MapperRow.svelte";
export let columnOptions: ColumnOption[];
export let tagsColumn: number;
export let globalNotetype: CsvMetadata_MappedNotetype | null;
export let state: ImportCsvState;
let lastNotetypeId: bigint | undefined = -1n;
let fieldNamesPromise: Promise<string[]>;
$: if (globalNotetype?.id !== lastNotetypeId) {
lastNotetypeId = globalNotetype?.id;
fieldNamesPromise =
globalNotetype === null
? Promise.resolve([])
: getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals);
}
const metadata = state.metadata;
const globalNotetype = state.globalNotetype;
const fieldNamesPromise = state.fieldNames;
const columnOptions = state.columnOptions;
</script>
{#if globalNotetype}
{#await fieldNamesPromise then fieldNames}
{#each fieldNames as label, idx}
<!-- first index is treated specially, because it must be assigned some column -->
<MapperRow
{label}
columnOptions={idx === 0 ? columnOptions.slice(1) : columnOptions}
bind:value={globalNotetype.fieldColumns[idx]}
/>
{/each}
{/await}
<Spacer --height="1.5rem" />
{/if}
<MapperRow label={tr.editingTags()} {columnOptions} bind:value={tagsColumn} />
<TitledContainer title={tr.importingFieldMapping()}>
{#if $globalNotetype !== null}
{#await $fieldNamesPromise then fieldNames}
{#each fieldNames as label, idx}
<!-- first index is treated specially, because it must be assigned some column -->
<MapperRow
{label}
columnOptions={idx === 0 ? $columnOptions.slice(1) : $columnOptions}
bind:value={$globalNotetype.fieldColumns[idx]}
/>
{/each}
{/await}
{/if}
<MapperRow
label={tr.editingTags()}
columnOptions={$columnOptions}
bind:value={$metadata.tagsColumn}
/>
</TitledContainer>

View file

@ -0,0 +1,84 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import { delimiterChoices } from "./choices";
import type { ImportCsvState } from "./lib";
import Preview from "./Preview.svelte";
export let state: ImportCsvState;
const metadata = state.metadata;
const settings = {
delimiter: {
title: tr.importingFieldSeparator(),
help: tr.importingFieldSeparatorHelp(),
url: HelpPage.TextImporting.root,
},
isHtml: {
title: tr.importingAllowHtmlInFields(),
help: tr.importingAllowHtmlInFieldsHelp(),
url: HelpPage.TextImporting.html,
},
};
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;
function openHelpModal(index: number): void {
modal.show();
carousel.to(index);
}
</script>
<TitledContainer title={tr.importingFile()}>
<HelpModal
title={tr.importingFile()}
url={HelpPage.TextImporting.root}
slot="tooltip"
{helpSections}
on:mount={(e) => {
modal = e.detail.modal;
carousel = e.detail.carousel;
}}
/>
<EnumSelectorRow
bind:value={$metadata.delimiter}
defaultValue={state.defaultDelimiter}
choices={delimiterChoices()}
disabled={$metadata.forceDelimiter}
>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("delimiter"))}
>
{settings.delimiter.title}
</SettingTitle>
</EnumSelectorRow>
<SwitchRow
bind:value={$metadata.isHtml}
defaultValue={state.defaultIsHtml}
disabled={$metadata.forceIsHtml}
>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("isHtml"))}
>
{settings.isHtml.title}
</SettingTitle>
</SwitchRow>
<Preview {state} />
</TitledContainer>

View file

@ -1,17 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let heading: string;
</script>
<h1>
{heading}
</h1>
<style lang="scss">
h1 {
padding-top: 0.5em;
}
</style>

View file

@ -1,23 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Switch from "../components/Switch.svelte";
export let isHtml: boolean;
export let disabled: boolean;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.importingAllowHtmlInFields()}
</Col>
<Col --col-size={1} --col-justify="flex-end">
<Switch id={undefined} bind:value={isHtml} {disabled} />
</Col>
</Row>

View file

@ -3,192 +3,18 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { DeckNameId } from "@tslib/anki/decks_pb";
import type { StringList } from "@tslib/anki/generic_pb";
import type {
CsvMetadata_Delimiter,
CsvMetadata_DupeResolution,
CsvMetadata_MappedNotetype,
CsvMetadata_MatchScope,
ImportResponse,
} from "@tslib/anki/import_export_pb";
import type { NotetypeNameId } from "@tslib/anki/notetypes_pb";
import { getCsvMetadata, importCsv, importDone } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import BackendProgressIndicator from "../components/BackendProgressIndicator.svelte";
import Col from "../components/Col.svelte";
import Container from "../components/Container.svelte";
import Row from "../components/Row.svelte";
import Spacer from "../components/Spacer.svelte";
import StickyHeader from "../components/StickyHeader.svelte";
import ImportLogPage from "../import-log/ImportLogPage.svelte";
import DeckDupeCheckSwitch from "./DeckDupeCheckSwitch.svelte";
import DeckSelector from "./DeckSelector.svelte";
import DelimiterSelector from "./DelimiterSelector.svelte";
import DupeResolutionSelector from "./DupeResolutionSelector.svelte";
import ImportPage from "../import-page/ImportPage.svelte";
import FieldMapper from "./FieldMapper.svelte";
import Header from "./Header.svelte";
import HtmlSwitch from "./HtmlSwitch.svelte";
import {
buildDeckOneof,
buildNotetypeOneof,
getColumnOptions,
tryGetDeckId,
tryGetGlobalNotetype,
} from "./lib";
import NotetypeSelector from "./NotetypeSelector.svelte";
import Preview from "./Preview.svelte";
import Tags from "./Tags.svelte";
import FileOptions from "./FileOptions.svelte";
import ImportOptions from "./ImportOptions.svelte";
import type { ImportCsvState } from "./lib";
export let path: string;
export let notetypeNameIds: NotetypeNameId[];
export let deckNameIds: DeckNameId[];
export let dupeResolution: CsvMetadata_DupeResolution;
export let matchScope: CsvMetadata_MatchScope;
export let delimiter: CsvMetadata_Delimiter;
export let forceDelimiter: boolean;
export let forceIsHtml: boolean;
export let isHtml: boolean;
export let globalTags: string[];
export let updatedTags: string[];
export let columnLabels: string[];
export let tagsColumn: number;
export let guidColumn: number;
export let preview: StringList[];
// Protobuf oneofs. Exactly one of these pairs is expected to be set.
export let notetypeColumn: number | null;
export let globalNotetype: CsvMetadata_MappedNotetype | null;
export let deckId: bigint | null;
export let deckColumn: number | null;
let importResponse: ImportResponse | undefined = undefined;
let lastNotetypeId = globalNotetype?.id;
let lastDelimeter = delimiter;
let importing = false;
$: columnOptions = getColumnOptions(
columnLabels,
preview[0].vals,
notetypeColumn,
deckColumn,
guidColumn,
);
$: getCsvMetadata({
path,
delimiter,
notetypeId: undefined,
deckId: undefined,
isHtml,
}).then((meta) => {
columnLabels = meta.columnLabels;
preview = meta.preview;
});
$: if (globalNotetype?.id !== lastNotetypeId || delimiter !== lastDelimeter) {
lastNotetypeId = globalNotetype?.id;
lastDelimeter = delimiter;
getCsvMetadata({
path,
delimiter,
notetypeId: globalNotetype?.id,
deckId: deckId ?? undefined,
}).then((meta) => {
globalNotetype = tryGetGlobalNotetype(meta);
deckId = tryGetDeckId(meta);
tagsColumn = meta.tagsColumn;
});
}
async function onImport(): Promise<ImportResponse> {
const result = await importCsv({
path,
metadata: {
dupeResolution,
matchScope,
delimiter,
forceDelimiter,
isHtml,
forceIsHtml,
globalTags,
updatedTags,
columnLabels,
tagsColumn,
guidColumn,
deck: buildDeckOneof(deckColumn, deckId),
notetype: buildNotetypeOneof(globalNotetype, notetypeColumn),
preview: [],
},
});
await importDone({});
importing = false;
return result;
}
export let state: ImportCsvState;
</script>
<div class="outer">
{#if importing}
<BackendProgressIndicator task={onImport} bind:result={importResponse} />
{:else if importResponse}
<ImportLogPage response={importResponse} params={{ path }} />
{:else}
<StickyHeader {path} onImport={() => (importing = true)} />
<Container class="csv-page">
<Row --cols={2}>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFile()} />
<Spacer --height="1.5rem" />
<DelimiterSelector bind:delimiter disabled={forceDelimiter} />
<HtmlSwitch bind:isHtml disabled={forceIsHtml} />
<Preview {columnOptions} {preview} />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingImportOptions()} />
<Spacer --height="1.5rem" />
{#if globalNotetype}
<NotetypeSelector
{notetypeNameIds}
bind:notetypeId={globalNotetype.id}
/>
{/if}
{#if deckId}
<DeckSelector {deckNameIds} bind:deckId />
{/if}
<DupeResolutionSelector bind:dupeResolution />
<DeckDupeCheckSwitch bind:matchScope />
<Tags bind:globalTags bind:updatedTags />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFieldMapping()} />
<Spacer --height="1.5rem" />
<FieldMapper
{columnOptions}
bind:globalNotetype
bind:tagsColumn
/>
</Container>
</Col>
</Row>
</Container>
{/if}
</div>
<style lang="scss">
.outer {
margin: 0 auto;
}
:global(.csv-page) {
--gutter-inline: 0.25rem;
:global(.row) {
// rows have negative margins by default
--bs-gutter-x: 0;
margin-bottom: 0.5rem;
}
}
</style>
<ImportPage path={state.path} importer={state}>
<Row><FileOptions {state} /></Row>
<Row><ImportOptions {state} /></Row>
<Row><FieldMapper {state} /></Row>
</ImportPage>

View file

@ -0,0 +1,153 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import TagsRow from "../components/TagsRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import { dupeResolutionChoices, matchScopeChoices } from "./choices";
import type { ImportCsvState } from "./lib";
export let state: ImportCsvState;
const metadata = state.metadata;
const globalNotetype = state.globalNotetype;
const deckId = state.deckId;
const settings = {
notetype: {
title: tr.notetypesNotetype(),
help: tr.importingNotetypeHelp(),
url: HelpPage.TextImporting.root,
},
deck: {
title: tr.decksDeck(),
help: tr.importingDeckHelp(),
url: HelpPage.TextImporting.root,
},
dupeResolution: {
title: tr.importingExistingNotes(),
help: tr.importingExistingNotesHelp(),
url: HelpPage.TextImporting.updating,
},
matchScope: {
title: tr.importingMatchScope(),
help: tr.importingMatchScopeHelp(),
url: HelpPage.TextImporting.updating,
},
globalTags: {
title: tr.importingTagAllNotes(),
help: tr.importingTagAllNotesHelp(),
url: HelpPage.TextImporting.root,
},
updatedTags: {
title: tr.importingTagUpdatedNotes(),
help: tr.importingTagUpdatedNotesHelp(),
url: HelpPage.TextImporting.root,
},
};
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;
function openHelpModal(index: number): void {
modal.show();
carousel.to(index);
}
</script>
<TitledContainer title={tr.importingImportOptions()}>
<HelpModal
title={tr.importingImportOptions()}
url={HelpPage.TextImporting.root}
slot="tooltip"
{helpSections}
on:mount={(e) => {
modal = e.detail.modal;
carousel = e.detail.carousel;
}}
/>
{#if $globalNotetype !== null}
<EnumSelectorRow
bind:value={$globalNotetype.id}
defaultValue={state.defaultNotetypeId}
choices={state.notetypeNameIds.map(({ id, name }) => {
return { label: name, value: id };
})}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("notetype"))}
>
{settings.notetype.title}
</SettingTitle>
</EnumSelectorRow>
{/if}
{#if $deckId !== null}
<EnumSelectorRow
bind:value={$deckId}
defaultValue={state.defaultDeckId}
choices={state.deckNameIds.map(({ id, name }) => {
return { label: name, value: id };
})}
>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("deck"))}
>
{settings.deck.title}
</SettingTitle>
</EnumSelectorRow>
{/if}
<EnumSelectorRow
bind:value={$metadata.dupeResolution}
defaultValue={0}
choices={dupeResolutionChoices()}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("dupeResolution"))}
>
{settings.dupeResolution.title}
</SettingTitle>
</EnumSelectorRow>
<EnumSelectorRow
bind:value={$metadata.matchScope}
defaultValue={0}
choices={matchScopeChoices()}
>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("matchScope"))}
>
{settings.matchScope.title}
</SettingTitle>
</EnumSelectorRow>
<TagsRow bind:tags={$metadata.globalTags} keyCombination={"Control+T"}>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("globalTags"))}
>
{settings.globalTags.title}
</SettingTitle>
</TagsRow>
<TagsRow bind:tags={$metadata.updatedTags} keyCombination={"Control+Shift+T"}>
<SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("updatedTags"))}
>
{settings.updatedTags.title}
</SettingTitle>
</TagsRow>
</TitledContainer>

View file

@ -1,31 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { NotetypeNameId } from "@tslib/anki/notetypes_pb";
import * as tr from "@tslib/ftl";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
export let notetypeNameIds: NotetypeNameId[];
export let notetypeId: bigint;
$: label = notetypeNameIds.find((n) => n.id === notetypeId)?.name;
</script>
<Row --cols={2}>
<Col --col-size={1}>
{tr.notetypesNotetype()}
</Col>
<Col --col-size={1}>
<Select bind:value={notetypeId} {label}>
{#each notetypeNameIds as { id, name }}
<SelectOption value={id}>{name}</SelectOption>
{/each}
</Select>
</Col>
</Row>

View file

@ -3,22 +3,22 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { StringList } from "@tslib/anki/generic_pb";
import { type ImportCsvState } from "./lib";
import type { ColumnOption } from "./lib";
export let state: ImportCsvState;
export let columnOptions: ColumnOption[];
export let preview: StringList[];
const metadata = state.metadata;
const columnOptions = state.columnOptions;
</script>
<div class="outer">
<table class="preview">
{#each columnOptions.slice(1) as { label, shortLabel }}
{#each $columnOptions.slice(1) as { label, shortLabel }}
<th>
{shortLabel || label}
</th>
{/each}
{#each preview as row}
{#each $metadata.preview as row}
<tr>
{#each row.vals as cell}
<td>{cell}</td>
@ -30,8 +30,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
.outer {
// approximate size based on body max width + margins
width: min(90vw, 65em);
overflow: auto;
}

View file

@ -1,38 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import { writable } from "svelte/store";
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import TagEditor from "../tag-editor/TagEditor.svelte";
export let globalTags: string[];
export let updatedTags: string[];
const globalTagsWritable = writable<string[]>(globalTags);
const updatedTagsWritable = writable<string[]>(updatedTags);
</script>
<Row --cols={2}>
<Col>{tr.importingTagAllNotes()}</Col>
<Col>
<TagEditor
tags={globalTagsWritable}
on:tagsupdate={({ detail }) => (globalTags = detail.tags)}
keyCombination={"Control+T"}
/>
</Col>
</Row>
<Row --cols={2}>
<Col>{tr.importingTagUpdatedNotes()}</Col>
<Col>
<TagEditor
tags={updatedTagsWritable}
on:tagsupdate={({ detail }) => (updatedTags = detail.tags)}
/>
</Col>
</Row>

54
ts/import-csv/choices.ts Normal file
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
import {
CsvMetadata_Delimiter,
CsvMetadata_DupeResolution,
CsvMetadata_MatchScope,
} from "@tslib/anki/import_export_pb";
import * as tr from "@tslib/ftl";
import type { Choice } from "components/EnumSelector.svelte";
export function delimiterChoices(): Choice<CsvMetadata_Delimiter>[] {
return [
{
label: tr.importingTab(),
value: CsvMetadata_Delimiter.TAB,
},
{
label: tr.importingPipe(),
value: CsvMetadata_Delimiter.PIPE,
},
{
label: tr.importingSemicolon(),
value: CsvMetadata_Delimiter.SEMICOLON,
},
{
label: tr.importingColon(),
value: CsvMetadata_Delimiter.COLON,
},
{
label: tr.importingComma(),
value: CsvMetadata_Delimiter.COMMA,
},
{
label: tr.studyingSpace(),
value: CsvMetadata_Delimiter.SPACE,
},
];
}
export function dupeResolutionChoices(): Choice<CsvMetadata_DupeResolution>[] {
return [
{ label: tr.importingUpdate(), value: CsvMetadata_DupeResolution.UPDATE },
{ label: tr.importingPreserve(), value: CsvMetadata_DupeResolution.PRESERVE },
{ label: tr.importingDuplicate(), value: CsvMetadata_DupeResolution.DUPLICATE },
];
}
export function matchScopeChoices(): Choice<CsvMetadata_MatchScope>[] {
return [
{ label: tr.notetypesNotetype(), value: CsvMetadata_MatchScope.NOTETYPE },
{ label: tr.importingNotetypeAndDeck(), value: CsvMetadata_MatchScope.NOTETYPE_AND_DECK },
];
}

View file

@ -7,6 +7,9 @@
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/transitions";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/carousel";
@import "sass/bootstrap-forms";
@import "sass/bootstrap-tooltip";

View file

@ -7,9 +7,10 @@ import { getCsvMetadata, getDeckNames, getNotetypeNames } from "@tslib/backend";
import { ModuleName, setupI18n } from "@tslib/i18n";
import { checkNightMode } from "@tslib/nightmode";
import { modalsKey } from "../components/context-keys";
import ErrorPage from "../components/ErrorPage.svelte";
import ImportCsvPage from "./ImportCsvPage.svelte";
import { tryGetDeckColumn, tryGetDeckId, tryGetGlobalNotetype, tryGetNotetypeColumn } from "./lib";
import { ImportCsvState } from "./lib";
const i18n = setupI18n({
modules: [
@ -22,11 +23,16 @@ const i18n = setupI18n({
ModuleName.NOTETYPES,
ModuleName.STUDYING,
ModuleName.ADDING,
ModuleName.HELP,
ModuleName.DECK_CONFIG,
],
});
export async function setupImportCsvPage(path: string): Promise<ImportCsvPage | ErrorPage> {
const context = new Map();
context.set(modalsKey, new Map());
checkNightMode();
return Promise.all([
getNotetypeNames({}),
getDeckNames({
@ -35,32 +41,13 @@ export async function setupImportCsvPage(path: string): Promise<ImportCsvPage |
}),
getCsvMetadata({ path }, { alertOnError: false }),
i18n,
]).then(([notetypes, decks, metadata, _i18n]) => {
]).then(([notetypes, decks, metadata]) => {
return new ImportCsvPage({
target: document.body,
props: {
path: path,
deckNameIds: decks.entries,
notetypeNameIds: notetypes.entries,
dupeResolution: metadata.dupeResolution,
matchScope: metadata.matchScope,
delimiter: metadata.delimiter,
forceDelimiter: metadata.forceDelimiter,
isHtml: metadata.isHtml,
forceIsHtml: metadata.forceIsHtml,
globalTags: metadata.globalTags,
updatedTags: metadata.updatedTags,
columnLabels: metadata.columnLabels,
tagsColumn: metadata.tagsColumn,
guidColumn: metadata.guidColumn,
preview: metadata.preview,
globalNotetype: tryGetGlobalNotetype(metadata),
// Unset oneof numbers default to 0, which also means n/a here,
// but it's vital to differentiate between unset and 0 when reserializing.
notetypeColumn: tryGetNotetypeColumn(metadata),
deckId: tryGetDeckId(metadata),
deckColumn: tryGetDeckColumn(metadata),
state: new ImportCsvState(path, notetypes, decks, metadata),
},
context,
});
}).catch((error) => {
return new ErrorPage({ target: document.body, props: { error } });

View file

@ -1,8 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { CsvMetadata, CsvMetadata_MappedNotetype } from "@tslib/anki/import_export_pb";
import type { DeckNameId, DeckNames } from "@tslib/anki/decks_pb";
import type { CsvMetadata, CsvMetadata_Delimiter, ImportResponse } from "@tslib/anki/import_export_pb";
import { type CsvMetadata_MappedNotetype } from "@tslib/anki/import_export_pb";
import type { NotetypeNameId, NotetypeNames } from "@tslib/anki/notetypes_pb";
import { getCsvMetadata, getFieldNames, importCsv } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { cloneDeep, isEqual, noop } from "lodash-es";
import type { Readable, Writable } from "svelte/store";
import { readable, writable } from "svelte/store";
export interface ColumnOption {
label: string;
@ -11,24 +18,164 @@ export interface ColumnOption {
disabled: boolean;
}
export function getColumnOptions(
columnLabels: string[],
firstRow: string[],
notetypeColumn: number | null,
deckColumn: number | null,
guidColumn: number,
export function getGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype | null {
return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null;
}
export function getDeckId(meta: CsvMetadata): bigint | null {
return meta.deck.case === "deckId" ? meta.deck.value : null;
}
export class ImportCsvState {
readonly path: string;
readonly deckNameIds: DeckNameId[];
readonly notetypeNameIds: NotetypeNameId[];
readonly defaultDelimiter: CsvMetadata_Delimiter;
readonly defaultIsHtml: boolean;
readonly defaultNotetypeId: bigint | null;
readonly defaultDeckId: bigint | null;
readonly metadata: Writable<CsvMetadata>;
readonly globalNotetype: Writable<CsvMetadata_MappedNotetype | null>;
readonly deckId: Writable<bigint | null>;
readonly fieldNames: Readable<Promise<string[]>>;
readonly columnOptions: Readable<ColumnOption[]>;
private lastMetadata: CsvMetadata;
private lastGlobalNotetype: CsvMetadata_MappedNotetype | null;
private lastDeckId: bigint | null;
private fieldNamesSetter: (val: Promise<string[]>) => void = noop;
private columnOptionsSetter: (val: ColumnOption[]) => void = noop;
constructor(path: string, notetypes: NotetypeNames, decks: DeckNames, metadata: CsvMetadata) {
this.path = path;
this.deckNameIds = decks.entries;
this.notetypeNameIds = notetypes.entries;
this.lastMetadata = cloneDeep(metadata);
this.metadata = writable(metadata);
this.metadata.subscribe(this.onMetadataChanged.bind(this));
const globalNotetype = getGlobalNotetype(metadata);
this.lastGlobalNotetype = cloneDeep(getGlobalNotetype(metadata));
this.globalNotetype = writable(cloneDeep(globalNotetype));
this.globalNotetype.subscribe(this.onGlobalNotetypeChanged.bind(this));
this.lastDeckId = getDeckId(metadata);
this.deckId = writable(getDeckId(metadata));
this.deckId.subscribe(this.onDeckIdChanged.bind(this));
this.fieldNames = readable(
globalNotetype === null
? Promise.resolve([])
: getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals),
(set) => {
this.fieldNamesSetter = set;
},
);
this.columnOptions = readable(getColumnOptions(metadata), (set) => {
this.columnOptionsSetter = set;
});
this.defaultDelimiter = metadata.delimiter;
this.defaultIsHtml = metadata.isHtml;
this.defaultNotetypeId = this.lastGlobalNotetype?.id || null;
this.defaultDeckId = this.lastDeckId;
}
doImport(): Promise<ImportResponse> {
return importCsv({
path: this.path,
metadata: { ...this.lastMetadata, preview: [] },
}, { alertOnError: false });
}
private async onMetadataChanged(changed: CsvMetadata) {
if (isEqual(changed, this.lastMetadata)) {
return;
}
const shouldRefetchMetadata = this.shouldRefetchMetadata(changed);
if (shouldRefetchMetadata) {
changed = await getCsvMetadata({
path: this.path,
delimiter: changed.delimiter,
notetypeId: getGlobalNotetype(changed)?.id,
deckId: getDeckId(changed) ?? undefined,
isHtml: changed.isHtml,
});
}
const globalNotetype = getGlobalNotetype(changed);
this.globalNotetype.set(globalNotetype);
if (globalNotetype !== null && globalNotetype.id !== getGlobalNotetype(this.lastMetadata)?.id) {
this.fieldNamesSetter(getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals));
}
if (this.shouldRebuildColumnOptions(changed)) {
this.columnOptionsSetter(getColumnOptions(changed));
}
this.lastMetadata = cloneDeep(changed);
if (shouldRefetchMetadata) {
this.metadata.set(changed);
}
}
private shouldRefetchMetadata(changed: CsvMetadata): boolean {
return changed.delimiter !== this.lastMetadata.delimiter || changed.isHtml !== this.lastMetadata.isHtml
|| getGlobalNotetype(changed)?.id !== getGlobalNotetype(this.lastMetadata)?.id;
}
private shouldRebuildColumnOptions(changed: CsvMetadata): boolean {
return !isEqual(changed.columnLabels, this.lastMetadata.columnLabels)
|| !isEqual(changed.preview[0], this.lastMetadata.preview[0]);
}
private onGlobalNotetypeChanged(globalNotetype: CsvMetadata_MappedNotetype | null) {
if (isEqual(globalNotetype, this.lastGlobalNotetype)) {
return;
}
this.lastGlobalNotetype = cloneDeep(globalNotetype);
if (globalNotetype !== null) {
this.metadata.update((metadata) => {
metadata.notetype.value = globalNotetype;
return metadata;
});
}
}
private onDeckIdChanged(deckId: bigint | null) {
if (deckId === this.lastDeckId) {
return;
}
this.lastDeckId = deckId;
if (deckId !== null) {
this.metadata.update((metadata) => {
metadata.deck.value = deckId;
return metadata;
});
}
}
}
function getColumnOptions(
metadata: CsvMetadata,
): ColumnOption[] {
const notetypeColumn = getNotetypeColumn(metadata);
const deckColumn = getDeckColumn(metadata);
return [{ label: tr.changeNotetypeNothing(), value: 0, disabled: false }].concat(
columnLabels.map((label, index) => {
metadata.columnLabels.map((label, index) => {
index += 1;
if (index === notetypeColumn) {
return columnOption(tr.notetypesNotetype(), true, index);
} else if (index === deckColumn) {
return columnOption(tr.decksDeck(), true, index);
} else if (index === guidColumn) {
} else if (index === metadata.guidColumn) {
return columnOption("GUID", true, index);
} else if (label === "") {
return columnOption(firstRow[index - 1], false, index, true);
return columnOption(metadata.preview[0].vals[index - 1], false, index, true);
} else {
return columnOption(label, false, index);
}
@ -50,42 +197,10 @@ function columnOption(
};
}
export function tryGetGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype | null {
return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null;
}
export function tryGetDeckId(meta: CsvMetadata): bigint | null {
return meta.deck.case === "deckId" ? meta.deck.value : null;
}
export function tryGetDeckColumn(meta: CsvMetadata): number | null {
function getDeckColumn(meta: CsvMetadata): number | null {
return meta.deck.case === "deckColumn" ? meta.deck.value : null;
}
export function tryGetNotetypeColumn(meta: CsvMetadata): number | null {
function getNotetypeColumn(meta: CsvMetadata): number | null {
return meta.notetype.case === "notetypeColumn" ? meta.notetype.value : null;
}
export function buildDeckOneof(
deckColumn: number | null,
deckId: bigint | null,
): CsvMetadata["deck"] {
if (deckColumn !== null) {
return { case: "deckColumn", value: deckColumn };
} else if (deckId !== null) {
return { case: "deckId", value: deckId };
}
throw new Error("missing column/id");
}
export function buildNotetypeOneof(
globalNotetype: CsvMetadata_MappedNotetype | null,
notetypeColumn: number | null,
): CsvMetadata["notetype"] {
if (globalNotetype !== null) {
return { case: "globalNotetype", value: globalNotetype };
} else if (notetypeColumn !== null) {
return { case: "notetypeColumn", value: notetypeColumn };
}
throw new Error("missing column/id");
}

View file

@ -1,40 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./import-log-base.scss";
import { ModuleName, setupI18n } from "@tslib/i18n";
import { checkNightMode } from "@tslib/nightmode";
import ImportLogPage from "./ImportLogPage.svelte";
import type { LogParams } from "./types";
const i18n = setupI18n({
modules: [
ModuleName.IMPORTING,
ModuleName.ADDING,
ModuleName.EDITING,
ModuleName.ACTIONS,
ModuleName.KEYBOARD,
],
});
export async function setupImportLogPage(
params: LogParams,
): Promise<ImportLogPage> {
await i18n;
checkNightMode();
return new ImportLogPage({
target: document.body,
props: {
params,
},
});
}
if (window.location.hash.startsWith("#test-")) {
const path = window.location.hash.replace("#test-", "");
setupImportLogPage({ type: "apkg", path });
}

View file

@ -4,49 +4,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { ImportResponse } from "@tslib/anki/import_export_pb";
import { importDone, importJsonFile, importJsonString } from "@tslib/backend";
import { bridgeCommand } from "@tslib/bridgecommand";
import * as tr from "@tslib/ftl";
import BackendProgressIndicator from "../components/BackendProgressIndicator.svelte";
import Container from "../components/Container.svelte";
import CloseButton from "./CloseButton.svelte";
import DetailsTable from "./DetailsTable.svelte";
import { getSummaries } from "./lib";
import QueueSummary from "./QueueSummary.svelte";
import type { LogParams } from "./types";
export let params: LogParams;
export let response: ImportResponse | undefined = undefined;
let result = response;
export let response: ImportResponse;
const result = response;
$: summaries = result ? getSummaries(result.log!) : [];
$: foundNotes = result?.log?.foundNotes ?? 0;
let closeButton: HTMLElement;
async function onImport(): Promise<ImportResponse | undefined> {
const postOptions = { alertOnError: false };
let result: ImportResponse | undefined;
try {
switch (params.type) {
case "json_file":
result = await importJsonFile({ val: params.path }, postOptions);
break;
case "json_string":
result = await importJsonString({ val: params.json }, postOptions);
break;
}
} catch (err) {
alert(err);
bridgeCommand("close");
throw err;
}
await importDone({});
return result;
}
</script>
<Container class="import-log-page">
<BackendProgressIndicator task={onImport} bind:result />
{#if result}
<p class="note-count">
{tr.importingNotesFoundInFile2({

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
-->
<script context="module" lang="ts">
export interface Importer {
doImport: () => Promise<ImportResponse>;
}
</script>
<script lang="ts">
import type { ImportResponse } from "@tslib/anki/import_export_pb";
import { importDone } from "@tslib/backend";
import BackendProgressIndicator from "../components/BackendProgressIndicator.svelte";
import Container from "../components/Container.svelte";
import ErrorPage from "../components/ErrorPage.svelte";
import StickyHeader from "../components/StickyHeader.svelte";
import ImportLogPage from "./ImportLogPage.svelte";
export let path: string;
export let importer: Importer;
export const noOptions: boolean = false;
let importResponse: ImportResponse | undefined = undefined;
let error: Error | undefined = undefined;
let importing = noOptions;
async function onImport(): Promise<ImportResponse> {
const result = await importer.doImport();
await importDone({});
importing = false;
return result;
}
</script>
{#if error}
<ErrorPage {error} />
{:else if importResponse}
<ImportLogPage response={importResponse} />
{:else if importing}
<BackendProgressIndicator task={onImport} bind:result={importResponse} bind:error />
{:else}
<div class="pre-import-page">
<StickyHeader {path} onImport={() => (importing = true)} />
<Container
breakpoint="sm"
--gutter-inline="0.25rem"
--gutter-block="0.75rem"
class="container-columns"
>
<slot />
</Container>
</div>
{/if}
<style lang="scss">
:global(.row) {
// rows have negative margins by default
--bs-gutter-x: 0;
margin-bottom: 0.5rem;
}
.pre-import-page {
margin: 0 auto;
}
</style>

54
ts/import-page/index.ts Normal file
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
import "./import-page-base.scss";
import { importJsonFile, importJsonString } from "@tslib/backend";
import { ModuleName, setupI18n } from "@tslib/i18n";
import { checkNightMode } from "@tslib/nightmode";
import ImportPage from "./ImportPage.svelte";
import type { LogParams } from "./types";
const i18n = setupI18n({
modules: [
ModuleName.IMPORTING,
ModuleName.ADDING,
ModuleName.EDITING,
ModuleName.ACTIONS,
ModuleName.KEYBOARD,
],
});
const postOptions = { alertOnError: false };
export async function setupImportPage(
params: LogParams,
): Promise<ImportPage> {
await i18n;
checkNightMode();
return new ImportPage({
target: document.body,
props: {
path: params.path,
noOptions: true,
importer: {
doImport: () => {
switch (params.type) {
case "json_file":
return importJsonFile({ val: params.path }, postOptions);
case "json_string":
return importJsonString({ val: params.json }, postOptions);
}
},
},
},
});
}
if (window.location.hash.startsWith("#test-")) {
const path = window.location.hash.replace("#test-", "");
setupImportPage({ type: "json_file", path });
}

View file

@ -23,12 +23,12 @@ export type NoteRow = {
};
type PathParams = {
type?: "apkg" | "json_file";
type: "json_file";
path: string;
};
type JsonParams = {
type?: "json_string";
type: "json_string";
path: string;
json: string;
};

View file

@ -38,4 +38,9 @@ export const HelpPage = {
updating: "https://docs.ankiweb.net/importing/packaged-decks.html#updating",
scheduling: "https://docs.ankiweb.net/importing/packaged-decks.html#scheduling",
},
TextImporting: {
root: "https://docs.ankiweb.net/importing/text-files.html",
updating: "https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating",
html: "https://docs.ankiweb.net/importing/text-files.html#html",
},
};