Anki/ts/routes/import-csv/lib.ts
Damien Elmes 9f55cf26fc
Switch to SvelteKit (#3077)
* Update to latest Node LTS

* Add sveltekit

* Split tslib into separate @generated and @tslib components

SvelteKit's path aliases don't support multiple locations, so our old
approach of using @tslib to refer to both ts/lib and out/ts/lib will no
longer work. Instead, all generated sources and their includes are
placed in a separate out/ts/generated folder, and imported via @generated
instead. This also allows us to generate .ts files, instead of needing
to output separate .d.ts and .js files.

* Switch package.json to module type

* Avoid usage of baseUrl

Incompatible with SvelteKit

* Move sass into ts; use relative links

SvelteKit's default sass support doesn't allow overriding loadPaths

* jest->vitest, graphs example working with yarn dev

* most pages working in dev mode

* Some fixes after rebasing

* Fix/silence some svelte-check errors

* Get image-occlusion working with Fabric types

* Post-rebase lock changes

* Editor is now checked

* SvelteKit build integrated into ninja

* Use the new SvelteKit entrypoint for pages like congrats/deck options/etc

* Run eslint once for ts/**; fix some tests

* Fix a bunch of issues introduced when rebasing over latest main

* Run eslint fix

* Fix remaining eslint+pylint issues; tests now all pass

* Fix some issues with a clean build

* Latest bufbuild no longer requires @__PURE__ hack

* Add a few missed dependencies

* Add yarn.bat to fix Windows build

* Fix pages failing to show when ANKI_API_PORT not defined

* Fix svelte-check and vitest on Windows

* Set node path in ./yarn

* Move svelte-kit output to ts/.svelte-kit

Sadly, I couldn't figure out a way to store it in out/ if out/ is
a symlink, as it breaks module resolution when SvelteKit is run.

* Allow HMR inside Anki

* Skip SvelteKit build when HMR is defined

* Fix some post-rebase issues

I should have done a normal merge instead.
2024-03-31 09:16:31 +01:00

206 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 "@generated/anki/decks_pb";
import type { CsvMetadata, CsvMetadata_Delimiter, ImportResponse } from "@generated/anki/import_export_pb";
import { type CsvMetadata_MappedNotetype } from "@generated/anki/import_export_pb";
import type { NotetypeNameId, NotetypeNames } from "@generated/anki/notetypes_pb";
import { getCsvMetadata, getFieldNames, importCsv } from "@generated/backend";
import * as tr from "@generated/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;
}