anki/ts/import-csv/lib.ts
RumovZ 850043b49b
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.
2023-09-14 09:06:15 +10:00

207 lines
7.6 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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;
shortLabel?: string;
value: number;
disabled: boolean;
}
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(
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 === metadata.guidColumn) {
return columnOption("GUID", true, index);
} else if (label === "") {
return columnOption(metadata.preview[0].vals[index - 1], false, index, true);
} else {
return columnOption(label, false, index);
}
}),
);
}
function columnOption(
label: string,
disabled: boolean,
index: number,
shortLabel?: boolean,
): ColumnOption {
return {
label: label ? `${index}: ${label}` : index.toString(),
shortLabel: shortLabel ? index.toString() : undefined,
value: index,
disabled,
};
}
function getDeckColumn(meta: CsvMetadata): number | null {
return meta.deck.case === "deckColumn" ? meta.deck.value : null;
}
function getNotetypeColumn(meta: CsvMetadata): number | null {
return meta.notetype.case === "notetypeColumn" ? meta.notetype.value : null;
}