diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 84409dbe..38568175 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -566,57 +566,92 @@ analytics-result-meta-truncated = { $rows -> # --- Import / export ------------------------------------------------------------- transfer-eyebrow = Přenos dat import-title = Import CSV -import-lead = Vyberte soubor v prohlížeči nebo vložte CSV. Řádky se ověřují proti živým strukturám tabulek před hromadným vkládáním přes gRPC. +import-lead = Import připravíte ve třech krocích: určíte, kam data směřují, řeknete, co je který sloupec vašeho souboru, a výsledek si prohlédnete dřív, než se cokoli zapíše. import-scope = Rozsah import-choose-scope = Vyberte globální nebo profil import-target-tables = Cílová tabulka import-choose-table = Vyberte tabulku import-csv-file = Soubor CSV import-csv-data = Data CSV -import-csv-format-hint = Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "name","number","active" +import-csv-format-hint = Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "Acme","12345678","true" +import-source-mode = Jak je zdroj uspořádán +import-source-header = Soubor má řádek záhlaví +import-source-data = Soubor obsahuje pouze data +import-source-template = Vygenerovat prázdnou šablonu +import-source-mode-hint = Toto je vždy vaše odpověď, nikdy odhad. Skutečný datový řádek může obsahovat slova, která vypadají přesně jako názvy sloupců, takže se zde nic samo nerozhoduje, zda je první řádek záhlaví. +import-continue = Pokračovat +import-carried = Import do tabulky { $table } v rozsahu { $scope }. import-error-title = CSV se nepodařilo importovat -import-import-rows = Importovat řádky +import-import-rows = Importovat připravená data import-success-title = Import dokončen import-success-message = Vloženo { $inserted -> - [one] { $inserted } záznam - [few] { $inserted } záznamy - *[other] { $inserted } záznamů -} z { $source_rows -> - [one] { $source_rows } řádku CSV - [few] { $source_rows } řádků CSV - *[other] { $source_rows } řádků CSV -} ve { $table_count -> - [one] { $table_count } tabulce - [few] { $table_count } tabulkách - *[other] { $table_count } tabulkách + [one] { $inserted } řádek + [few] { $inserted } řádky + *[other] { $inserted } řádků +} do tabulky { $table }, z { $source_rows -> + [one] { $source_rows } připraveného řádku + [few] { $source_rows } připravených řádků + *[other] { $source_rows } připravených řádků }. + +# --- Krok 2a: mapování ----------------------------------------------------- +import-mapping-heading = Pošlete každou pozici do sloupce tabulky { $table } +import-mapping-hint = Hodnoty se umisťují podle pozice, nikoli podle názvu. Název ze zdroje se zobrazuje jen proto, abyste pozice rozeznali; o ničem nerozhoduje. Pozice ponechaná na Ignorovat se neimportuje a sloupec, který zde nevyberete, se ponechá tabulce. +import-mapping-rows = Soubor obsahuje { $rows } datových řádků. +import-th-position = Pozice +import-th-source-name = Název ve zdroji +import-th-example = Ukázková hodnota +import-th-destination = Poslat do +import-destination-ignore = Ignorovat +import-back-to-source = Zpět na zdroj +import-to-preview = Připravit a zobrazit + +# --- Krok 2b: generátor šablony -------------------------------------------- +import-template-heading = Sestavte šablonu pro tabulku { $table } +import-template-hint = Zaškrtněte sloupce, které má soubor obsahovat, a uspořádejte je v pořadí, v jakém je chcete vyplňovat. +import-th-order = Pořadí +import-th-include = Zahrnout +import-required-column = povinný +import-template-generated = Záhlaví, které se vygeneruje: +import-template-nothing-chosen = Zaškrtněte alespoň jeden sloupec. +import-template-round-trip = Vyplňte tento soubor jinde a pak jej přineste zpět s vybranou možností "Soubor má řádek záhlaví". Protože jde o vlastní názvy sloupců tabulky, každá pozice už bude namířena na správný sloupec a vy ji jen potvrdíte. +import-download-template = Stáhnout šablonu + +# --- Krok 3: náhled -------------------------------------------------------- +import-preview-heading = Připravený import pro tabulku { $table } +import-summary-rows = Řádky zdroje +import-summary-columns-used = Použité sloupce +import-summary-ignored = Ignorované pozice zdroje +import-summary-omitted = Vynechané cílové sloupce +import-summary-missing-required = Tento import je nevyplňuje a tabulka je deklaruje jako NOT NULL: { $columns }. Pokud tyto sloupce nemají výchozí hodnotu, server řádky odmítne. +import-preview-more-rows = Dalších { $rows } řádků je připraveno a zde se nezobrazuje. +import-prepared-csv = Připravené CSV +import-prepared-csv-hint = Toto je soubor, který se importuje, a tentýž soubor, který předá stažení. +import-download-prepared = Stáhnout připravené CSV +import-back-to-mapping = Zpět na mapování +import-move-up = Posunout { $name } nahoru +import-move-down = Posunout { $name } dolů + +# --- Odmítnutí ------------------------------------------------------------- import-err-select-profile = Vyberte rozsah. import-err-tables-required = Vyberte cílovou tabulku. import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV. import-err-unknown-profile = Neznámý rozsah. import-err-tables-not-in-profile = Cílová tabulka nepatří do vybraného rozsahu. import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. -import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'. -import-err-column-not-importable = Sloupec '{ $column }' není importovatelný pro tabulku '{ $table }'. -import-err-csv-row = Řádek CSV { $row }: { $error } -import-err-multi-headers = Více-tabulkové CSV potřebuje řádek s hlavičkou tabulek a řádek s hlavičkou sloupců. -import-err-first-row = První řádek CSV musí obsahovat jen vybrané názvy tabulek. -import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou délku. -import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička. -import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat. -import-err-header-padded = Hlavička '{ $header }' má kolem sebe mezery, takže není sloupcem '{ $trimmed }'. Odstraňte je, nebo použijte „Normalizovat hlavičky“. -import-err-duplicate-header = Hlavička '{ $header }' se u téže tabulky vyskytuje dvakrát, takže není jasné, do kterého sloupce hodnoty patří. -import-err-column-near-match = Hlavička '{ $header }' není sloupcem tabulky '{ $table }', ale '{ $column }' ano - liší se jen mezerami nebo velikostí písmen. Opravte hlavičku, nebo použijte „Normalizovat hlavičky“ a výsledek si před importem prohlédněte. -import-normalize = Normalizovat hlavičky -import-normalize-hint = Přepíše řádky hlaviček na názvy sloupců tabulky. Nic se neimportuje - změněné CSV nejprve uvidíte. -import-normalized-message = Hlavičky přepsány. Než CSV naimportujete, přečtěte si ho níže. -import-normalized-unchanged = Hlavičky už tabulce přesně odpovídají; nic se nezměnilo. -import-system-columns = Soubor obsahuje systémové sloupce -import-system-columns-hint = Zaškrtněte pro soubor exportovaný se systémovými sloupci. Načte se tak, jak je: 'deleted' se zapíše ze souboru, takže smazaný řádek přijde jako smazaný. 'id', 'row_revision' a 'created_at' přiděluje server a přenést je nelze. -import-err-system-columns-present = Soubor obsahuje systémové sloupce ({ $columns }). Zaškrtněte „Soubor obsahuje systémové sloupce“, aby se načetl tak, jak je, nebo je ze souboru odstraňte. -import-success-ignored-system = { $columns } přiděluje server, importované řádky proto nesou nové. +import-err-no-importable-columns = Tabulka '{ $table }' nemá žádné sloupce, do kterých by import mohl zapisovat. +import-err-csv-row = Řádek { $row }: { $error } +import-err-row-width = Každý řádek musí obsahovat stejný počet pozic. Tento soubor začíná s { $expected } a později má řádek s { $found }. +import-err-no-data-rows = Soubor neobsahuje žádné datové řádky. +import-err-source-mode = Uveďte, jak je zdroj uspořádán. +import-err-template-has-no-source = Šablona se generuje z tabulky, takže není co připravovat ze souboru. +import-err-template-empty = Zaškrtněte pro šablonu alespoň jeden sloupec. +import-err-mapping-stale = Mapování bylo sestaveno pro soubor s { $mapped } pozicemi a tento má { $positions }. Připravte jej znovu. +import-err-mapping-unknown-column = Pozice { $position } míří na '{ $column }', což není sloupec, do kterého lze do této tabulky importovat. Tabulka se mohla změnit, zatímco byl formulář otevřený. +import-err-mapping-duplicate = Dvě pozice míří na '{ $column }'. Sloupec lze naplnit jen z jedné pozice. +import-err-mapping-empty = Všechny pozice jsou nastaveny na Ignorovat, takže není co importovat. import-err-missing-type = Chybí typ sloupce '{ $column }' -import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění k vkládání, aby do ní šlo načítat. +import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění ke vkládání, aby do ní šlo načítat. import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu. import-err-strict-csv = Neplatný formát CSV. Každé pole musí být uzavřeno v dvojitých uvozovkách, pole musí být oddělena čárkami a každý záznam musí zůstat na jednom řádku. import-err-empty = CSV je prázdné. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 2de7015f..f9fbf459 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -556,52 +556,88 @@ analytics-result-meta-truncated = { $rows -> # --- Import / export ------------------------------------------------------- transfer-eyebrow = Data transfer import-title = Import CSV -import-lead = Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion. +import-lead = Prepare an import in three steps: say where the data is going, say what each column of your file is, then look at the result before anything is written. import-scope = Scope import-choose-scope = Choose global or a profile import-target-tables = Target table import-choose-table = Choose a table import-csv-file = CSV file import-csv-data = CSV data -import-csv-format-hint = Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "name","number","active" +import-csv-format-hint = Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "Acme","12345678","true" +import-source-mode = How the source is laid out +import-source-header = The file has a header row +import-source-data = The file is data only +import-source-template = Generate an empty template +import-source-mode-hint = This is always your answer, never a guess. A real data row can hold words that read exactly like column names, so nothing here decides on its own whether the first row is a header. +import-continue = Continue +import-carried = Importing into { $table } in { $scope }. import-error-title = Could not import CSV -import-import-rows = Import rows +import-import-rows = Import prepared data import-success-title = Import complete import-success-message = Inserted { $inserted -> - [one] { $inserted } record - *[other] { $inserted } records -} from { $source_rows -> - [one] { $source_rows } CSV row - *[other] { $source_rows } CSV rows -} across { $table_count -> - [one] { $table_count } table - *[other] { $table_count } tables + [one] { $inserted } row + *[other] { $inserted } rows +} into { $table }, from { $source_rows -> + [one] { $source_rows } prepared row + *[other] { $source_rows } prepared rows }. + +# --- Step 2a: mapping ------------------------------------------------------ +import-mapping-heading = Send each position to a column of { $table } +import-mapping-hint = Values are placed by position, not by name. A source name is shown only so you can tell the positions apart; it decides nothing. A position left on Ignore is not imported, and a column not chosen here is left to the table. +import-mapping-rows = The file holds { $rows } data rows. +import-th-position = Position +import-th-source-name = Source name +import-th-example = Example value +import-th-destination = Send to +import-destination-ignore = Ignore +import-back-to-source = Back to the source +import-to-preview = Prepare and preview + +# --- Step 2b: template generator ------------------------------------------- +import-template-heading = Build a template for { $table } +import-template-hint = Tick the columns the file should contain and arrange them in the order you want to fill them in. +import-th-order = Order +import-th-include = Include +import-required-column = required +import-template-generated = The header this generates: +import-template-nothing-chosen = Tick at least one column. +import-template-round-trip = Fill this in elsewhere, then bring it back with "The file has a header row" selected. Because these are the table's own column names, every position will already be pointed at the right column for you to confirm. +import-download-template = Download template + +# --- Step 3: preview ------------------------------------------------------- +import-preview-heading = Prepared import for { $table } +import-summary-rows = Source rows +import-summary-columns-used = Columns used +import-summary-ignored = Source positions ignored +import-summary-omitted = Destination columns omitted +import-summary-missing-required = Not filled in by this import, and declared NOT NULL by the table: { $columns }. If those columns have no default, the server will refuse the rows. +import-preview-more-rows = { $rows } further rows are prepared and not shown here. +import-prepared-csv = The prepared CSV +import-prepared-csv-hint = This is the file that gets imported, and the same file the download hands over. +import-download-prepared = Download prepared CSV +import-back-to-mapping = Back to the mapping +import-move-up = Move { $name } up +import-move-down = Move { $name } down + +# --- Refusals -------------------------------------------------------------- import-err-select-profile = Select a scope. import-err-tables-required = Select a target table. import-err-csv-required = Choose a CSV file or paste CSV data. import-err-unknown-profile = Unknown scope. import-err-tables-not-in-profile = The target table does not belong to the selected scope. import-err-missing-structure = The backend omitted a requested table structure. -import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'. -import-err-column-not-importable = Column '{ $column }' is not importable for table '{ $table }'. -import-err-csv-row = CSV row { $row }: { $error } -import-err-multi-headers = Multi-table CSV needs a table-header row and a column-header row. -import-err-first-row = The first CSV row must contain only selected table names. -import-err-header-lengths = Table and column header rows have different lengths. -import-err-row-width = A CSV data row has a different number of fields than the header. -import-err-no-data-rows = CSV contains headers but no data rows. -import-err-header-padded = Header '{ $header }' has spaces around it, so it is not the column '{ $trimmed }'. Remove them, or use “Normalize headers”. -import-err-duplicate-header = Header '{ $header }' appears twice for the same table, so it is not clear which column the values belong to. -import-err-column-near-match = Header '{ $header }' is not a column of table '{ $table }', but '{ $column }' is - they differ only in spacing or capitalisation. Fix the header, or use “Normalize headers” to rewrite it and see the result before importing. -import-normalize = Normalize headers -import-normalize-hint = Rewrites the header rows to the table's own column names. Nothing is imported - you see the changed CSV first. -import-normalized-message = Headers rewritten. Read the CSV below before importing it. -import-normalized-unchanged = Headers already match the table exactly; nothing was changed. -import-system-columns = File includes system columns -import-system-columns-hint = Tick this for a file exported with system columns. It loads as it stands: 'deleted' is written from the file, so a row that left deleted arrives deleted. 'id', 'row_revision' and 'created_at' are assigned by the server and cannot be carried over. -import-err-system-columns-present = The file contains system columns ({ $columns }). Tick “File includes system columns” to import it as it stands, or remove those columns from the file. -import-success-ignored-system = { $columns } are assigned by the server, so the imported rows carry new ones. +import-err-no-importable-columns = Table '{ $table }' has no columns an import can write into. +import-err-csv-row = Row { $row }: { $error } +import-err-row-width = Every row must hold the same number of positions. This file starts with { $expected } and later has a row of { $found }. +import-err-no-data-rows = The file holds no data rows. +import-err-source-mode = Say how the source is laid out. +import-err-template-has-no-source = A template is generated from the table, so there is no file to prepare. +import-err-template-empty = Tick at least one column for the template. +import-err-mapping-stale = The mapping was built for a file of { $mapped } positions and this one has { $positions }. Prepare it again. +import-err-mapping-unknown-column = Position { $position } is pointed at '{ $column }', which is not a column this table can be imported into. The table may have changed while the form was open. +import-err-mapping-duplicate = Two positions are pointed at '{ $column }'. A column can be filled from one position only. +import-err-mapping-empty = Every position is set to Ignore, so there is nothing to import. import-err-missing-type = Missing type for column '{ $column }' import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded. import-err-unterminated-quote = CSV contains an unterminated quoted value. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 3ae8875b..a253e474 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -566,55 +566,88 @@ analytics-result-meta-truncated = { $rows -> # --- Import / export --------------------------------------------------------- transfer-eyebrow = Prenos údajov import-title = Import CSV -import-lead = Vyberte súbor v prehliadači alebo vložte CSV. Riadky sa overujú proti živým štruktúram tabuliek pred hromadným vkladaním cez gRPC. +import-lead = Import pripravíte v troch krokoch: určíte, kam údaje smerujú, poviete, čo je ktorý stĺpec vášho súboru, a výsledok si pozriete skôr, než sa čokoľvek zapíše. import-scope = Rozsah import-choose-scope = Vyberte globálne alebo profil import-target-tables = Cieľová tabuľka import-choose-table = Vyberte tabuľku import-csv-file = Súbor CSV import-csv-data = Údaje CSV -import-csv-format-hint = Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "name","number","active" +import-csv-format-hint = Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "Acme","12345678","true" +import-source-mode = Ako je zdroj usporiadaný +import-source-header = Súbor má riadok hlavičky +import-source-data = Súbor obsahuje len údaje +import-source-template = Vygenerovať prázdnu šablónu +import-source-mode-hint = Toto je vždy vaša odpoveď, nikdy odhad. Skutočný riadok údajov môže obsahovať slová, ktoré vyzerajú presne ako názvy stĺpcov, takže sa tu nič samo nerozhoduje, či je prvý riadok hlavička. +import-continue = Pokračovať +import-carried = Import do tabuľky { $table } v rozsahu { $scope }. import-error-title = CSV sa nepodarilo importovať -import-import-rows = Importovať riadky +import-import-rows = Importovať pripravené údaje import-success-title = Import dokončený -import-success-message = Vložené { $inserted -> - [one] { $inserted } záznam - [few] { $inserted } záznamy - *[other] { $inserted } záznamov -} z { $source_rows -> - [one] { $source_rows } riadku CSV - [few] { $source_rows } riadkov CSV - *[other] { $source_rows } riadkov CSV -} v { $table_count -> - [one] { $table_count } tabuľke - [few] { $table_count } tabuľkách - *[other] { $table_count } tabuľkách +import-success-message = Vložený { $inserted -> + [one] { $inserted } riadok + *[other] { $inserted } riadkov +} do tabuľky { $table }, z { $source_rows -> + [one] { $source_rows } pripraveného riadka + *[other] { $source_rows } pripravených riadkov }. + +# --- Krok 2a: mapovanie ---------------------------------------------------- +import-mapping-heading = Pošlite každú pozíciu do stĺpca tabuľky { $table } +import-mapping-hint = Hodnoty sa umiestňujú podľa pozície, nie podľa názvu. Názov zo zdroja sa zobrazuje len preto, aby ste pozície rozoznali; nerozhoduje o ničom. Pozícia ponechaná na Ignorovať sa neimportuje a stĺpec, ktorý tu nevyberiete, sa ponechá tabuľke. +import-mapping-rows = Súbor obsahuje { $rows } riadkov údajov. +import-th-position = Pozícia +import-th-source-name = Názov v zdroji +import-th-example = Ukážková hodnota +import-th-destination = Poslať do +import-destination-ignore = Ignorovať +import-back-to-source = Späť na zdroj +import-to-preview = Pripraviť a zobraziť + +# --- Krok 2b: generátor šablóny -------------------------------------------- +import-template-heading = Zostavte šablónu pre tabuľku { $table } +import-template-hint = Zaškrtnite stĺpce, ktoré má súbor obsahovať, a usporiadajte ich v poradí, v akom ich chcete vypĺňať. +import-th-order = Poradie +import-th-include = Zahrnúť +import-required-column = povinný +import-template-generated = Hlavička, ktorá sa vygeneruje: +import-template-nothing-chosen = Zaškrtnite aspoň jeden stĺpec. +import-template-round-trip = Vyplňte tento súbor inde a potom ho prineste späť s vybranou možnosťou "Súbor má riadok hlavičky". Keďže ide o vlastné názvy stĺpcov tabuľky, každá pozícia už bude nasmerovaná na správny stĺpec a vy ju len potvrdíte. +import-download-template = Stiahnuť šablónu + +# --- Krok 3: náhľad -------------------------------------------------------- +import-preview-heading = Pripravený import pre tabuľku { $table } +import-summary-rows = Riadky zdroja +import-summary-columns-used = Použité stĺpce +import-summary-ignored = Ignorované pozície zdroja +import-summary-omitted = Vynechané cieľové stĺpce +import-summary-missing-required = Tento import ich nevypĺňa a tabuľka ich deklaruje ako NOT NULL: { $columns }. Ak tieto stĺpce nemajú predvolenú hodnotu, server riadky odmietne. +import-preview-more-rows = Ďalších { $rows } riadkov je pripravených a tu sa nezobrazuje. +import-prepared-csv = Pripravené CSV +import-prepared-csv-hint = Toto je súbor, ktorý sa importuje, a ten istý súbor, ktorý odovzdá stiahnutie. +import-download-prepared = Stiahnuť pripravené CSV +import-back-to-mapping = Späť na mapovanie +import-move-up = Posunúť { $name } nahor +import-move-down = Posunúť { $name } nadol + +# --- Odmietnutia ----------------------------------------------------------- import-err-select-profile = Vyberte rozsah. import-err-tables-required = Vyberte cieľovú tabuľku. import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV. import-err-unknown-profile = Neznámy rozsah. import-err-tables-not-in-profile = Cieľová tabuľka nepatrí do vybraného rozsahu. import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. -import-err-no-importable-columns = CSV nemá importovateľné stĺpce pre tabuľku '{ $table }'. -import-err-column-not-importable = Stĺpec '{ $column }' nie je importovateľný pre tabuľku '{ $table }'. -import-err-csv-row = Riadok CSV { $row }: { $error } -import-err-multi-headers = Viac-tabuľkové CSV potrebuje riadok s hlavičkou tabuliek a riadok s hlavičkou stĺpcov. -import-err-first-row = Prvý riadok CSV musí obsahovať len vybrané názvy tabuliek. -import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu dĺžku. -import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička. -import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov. -import-err-header-padded = Hlavička '{ $header }' má okolo seba medzery, takže nie je stĺpcom '{ $trimmed }'. Odstráňte ich, alebo použite „Normalizovať hlavičky“. -import-err-duplicate-header = Hlavička '{ $header }' sa pri tej istej tabuľke vyskytuje dvakrát, takže nie je jasné, do ktorého stĺpca hodnoty patria. -import-err-column-near-match = Hlavička '{ $header }' nie je stĺpcom tabuľky '{ $table }', ale '{ $column }' áno - líšia sa len medzerami alebo veľkosťou písmen. Opravte hlavičku, alebo použite „Normalizovať hlavičky“ a výsledok si pred importom pozrite. -import-normalize = Normalizovať hlavičky -import-normalize-hint = Prepíše riadky hlavičiek na názvy stĺpcov tabuľky. Nič sa neimportuje - zmenené CSV najprv uvidíte. -import-normalized-message = Hlavičky prepísané. Kým CSV naimportujete, prečítajte si ho nižšie. -import-normalized-unchanged = Hlavičky už tabuľke presne zodpovedajú; nič sa nezmenilo. -import-system-columns = Súbor obsahuje systémové stĺpce -import-system-columns-hint = Zaškrtnite pre súbor exportovaný so systémovými stĺpcami. Načíta sa tak, ako je: 'deleted' sa zapíše zo súboru, takže zmazaný riadok príde ako zmazaný. 'id', 'row_revision' a 'created_at' prideľuje server a preniesť ich nemožno. -import-err-system-columns-present = Súbor obsahuje systémové stĺpce ({ $columns }). Zaškrtnite „Súbor obsahuje systémové stĺpce“, aby sa načítal tak, ako je, alebo ich zo súboru odstráňte. -import-success-ignored-system = { $columns } prideľuje server, importované riadky preto nesú nové. +import-err-no-importable-columns = Tabuľka '{ $table }' nemá žiadne stĺpce, do ktorých by import mohol zapisovať. +import-err-csv-row = Riadok { $row }: { $error } +import-err-row-width = Každý riadok musí obsahovať rovnaký počet pozícií. Tento súbor začína s { $expected } a neskôr má riadok s { $found }. +import-err-no-data-rows = Súbor neobsahuje žiadne riadky údajov. +import-err-source-mode = Uveďte, ako je zdroj usporiadaný. +import-err-template-has-no-source = Šablóna sa generuje z tabuľky, takže nie je čo pripravovať zo súboru. +import-err-template-empty = Zaškrtnite pre šablónu aspoň jeden stĺpec. +import-err-mapping-stale = Mapovanie bolo zostavené pre súbor s { $mapped } pozíciami a tento má { $positions }. Pripravte ho znova. +import-err-mapping-unknown-column = Pozícia { $position } smeruje na '{ $column }', čo nie je stĺpec, do ktorého sa dá do tejto tabuľky importovať. Tabuľka sa mohla zmeniť, kým bol formulár otvorený. +import-err-mapping-duplicate = Dve pozície smerujú na '{ $column }'. Stĺpec sa dá naplniť len z jednej pozície. +import-err-mapping-empty = Všetky pozície sú nastavené na Ignorovať, takže nie je čo importovať. import-err-missing-type = Chýba typ stĺpca '{ $column }' import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať. import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu. diff --git a/web/src/lib.rs b/web/src/lib.rs index c2f01733..e04927b8 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -469,6 +469,11 @@ mod tests { ("/admin/validation/rules", ""), ("/admin/validation/sets", ""), ("/admin/import", ""), + ("/admin/import/source", ""), + ("/admin/import/prepare", ""), + ("/admin/import/preview", ""), + ("/admin/import/prepared.csv", ""), + ("/admin/import/template.csv", ""), ("/admin/export.csv", ""), ] { let response = test_router() diff --git a/web/src/pages/import_export/common/schema.rs b/web/src/pages/import_export/common/schema.rs index 9b7bcee9..d283b98f 100644 --- a/web/src/pages/import_export/common/schema.rs +++ b/web/src/pages/import_export/common/schema.rs @@ -6,12 +6,12 @@ use crate::{i18n::Locale, tr}; use crate::definitions::table_structure::TableStructureResponse; -/// The columns an import can write, and the export's default header. +/// The export's default header. /// -/// Both ends use this list, so a file the default export writes is a file the -/// import accepts. That only holds if every system column is left out: a row is -/// inserted with `post_table_data`, which takes user columns and nothing else, -/// so exporting `row_revision` or `created_at` produced a file whose own +/// A file the default export writes is a file the import can be pointed at +/// column for column. That only holds if every system column is left out: a row +/// is inserted with `post_table_data`, which takes user columns and nothing +/// else, so exporting `row_revision` or `created_at` produced a file whose own /// re-import the server answered with `Invalid column`. The names come from /// the server's declarations rather than a list spelled out here, so a system /// column added there is excluded here too. @@ -80,24 +80,49 @@ fn is_read_omitted_column(name: &str) -> bool { .any(|column| column.name == name) } -/// Column names keyed by their lowercased form, for the one caller allowed to -/// match loosely: the normalizer, which rewrites a header into the column's own -/// spelling and shows the user the result before anything is imported. +/// The columns an import may write into: the destinations the mapping step +/// offers, in the order the table declares them. /// -/// Two columns that fold to the same key leave that key out entirely. There is -/// no answer to "which one did the header mean", and inventing one would be the -/// normalizer quietly choosing for the user. -pub(crate) fn folded_column_lookup(columns: &[String]) -> HashMap { - let mut lookup: HashMap> = HashMap::new(); - for column in columns { - lookup - .entry(column.to_lowercase()) - .and_modify(|entry| *entry = None) - .or_insert_with(|| Some(column.clone())); - } - lookup - .into_iter() - .filter_map(|(folded, column)| Some((folded, column?))) +/// This is the list the whole import rests on. A source position lands in a +/// column because the user picked it from here, so what is not here cannot be +/// written to at all — which is why the exclusions are the server's own flags +/// rather than a guess: +/// +/// * `is_primary_key` — `id` comes from a sequence. +/// * `read_only` — the server sets this for a quantity-ledger column and for a +/// link projection, and refuses an insert that names either. Accounting +/// columns are marked `generated` but *not* read-only, so they stay: they are +/// a user's to fill in. +/// * system columns, except the ones an insert actually takes. `deleted` is +/// offered, because writing it is how a file that recorded deleted rows loads +/// back as deleted rows; `row_revision` and `created_at` are not, because the +/// server assigns them and answers `Invalid column` to anything else. +pub(crate) fn importable_columns(schema: &TableStructureResponse) -> Vec { + schema + .columns + .iter() + .filter(|column| !column.is_primary_key && !column.read_only) + .filter(|column| { + !is_system_column(&column.name) || is_importable_system_column(&column.name) + }) + .map(|column| column.name.clone()) + .collect() +} + +/// The writable columns the table declares `NOT NULL`. +/// +/// Shown in the preview as a warning rather than enforced as a rule: a +/// `NOT NULL` column may still have a default, and `information_schema` does +/// not say which do. Refusing here would block imports the server would have +/// accepted, so the server stays the one that decides and this only tells the +/// user which columns are the likely reason if it refuses. +pub(crate) fn required_columns(schema: &TableStructureResponse) -> Vec { + schema + .columns + .iter() + .filter(|column| !column.is_primary_key && !column.read_only) + .filter(|column| !is_system_column(&column.name) && !column.is_nullable) + .map(|column| column.name.clone()) .collect() } @@ -160,6 +185,7 @@ mod tests { name: name.to_string(), data_type: "TEXT".to_string(), is_primary_key, + is_nullable: true, ..Default::default() } } @@ -207,4 +233,65 @@ mod tests { ] ); } + + /// What the mapping step may offer as a destination. `deleted` is in, + /// because an insert takes it; the columns the server assigns and the ones + /// it marks read-only are out, because an insert naming them is refused. + #[test] + fn only_the_columns_an_insert_takes_are_offered_as_destinations() { + let ledger = TableColumn { + read_only: true, + ..column("stock", false) + }; + // Accounting columns are generated companions, but the server leaves + // them writable — so they are a destination like any other. + let accounting = TableColumn { + generated: true, + generated_from: "accounting".to_string(), + ..column("debit", false) + }; + let schema = TableStructureResponse { + columns: vec![ + column("id", true), + column("deleted", false), + column("row_revision", false), + column("number", false), + ledger, + accounting, + column("created_at", false), + ], + }; + + assert_eq!( + importable_columns(&schema), + vec![ + "deleted".to_string(), + "number".to_string(), + "debit".to_string(), + ] + ); + } + + /// Only the user's own `NOT NULL` columns are reported as required. + /// `deleted` is `NOT NULL` on every managed table and has a default, so + /// reporting it would warn about every import ever prepared. + #[test] + fn required_columns_are_the_users_own_not_null_ones() { + let schema = TableStructureResponse { + columns: vec![ + column("id", true), + TableColumn { + is_nullable: false, + ..column("deleted", false) + }, + TableColumn { + is_nullable: false, + ..column("number", false) + }, + column("note", false), + ], + }; + + assert_eq!(required_columns(&schema), vec!["number".to_string()]); + } } diff --git a/web/src/pages/import_export/import/loader.rs b/web/src/pages/import_export/import/loader.rs index 7bb599c8..c3e9ac63 100644 --- a/web/src/pages/import_export/import/loader.rs +++ b/web/src/pages/import_export/import/loader.rs @@ -4,20 +4,20 @@ use crate::AppState; use super::{ super::common::loader::{LoadError, load_catalog}, - state::{ImportForm, ImportPageState}, + state::{ImportForm, ImportPageState, Step}, }; pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, form: ImportForm, - error: Option, + step: Step, ) -> Result { let catalog = load_catalog(state, headers, crate::authz::IMPORT, "insert").await?; Ok(ImportPageState { nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization), catalog, form, - error, + step, }) } diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index 62f8a8c9..d0fcff80 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -1,12 +1,12 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use axum::{ extract::State, - http::HeaderMap, + http::{HeaderMap, HeaderValue, header}, response::{Html, IntoResponse, Redirect, Response}, }; -// The table checkboxes post `table_names` once per checked box, and -// `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`. +// A mapping posts `mapping` once per source position, and `axum::Form` +// (serde_urlencoded) cannot decode repeated keys into a `Vec`. use axum_extra::extract::Form; use crate::{ @@ -15,74 +15,55 @@ use crate::{ table_structure::GetTableStructureRequest, tables_data::{PostTableDataBulkRequest, PostTableDataBulkRow}, }, - {i18n::Locale, tr}, services::{authenticated_request, reject_cross_site}, + {i18n::Locale, tr}, }; use super::{ super::common::{ - csv::{parse_csv, write_record}, loader::LoadError, - schema::{ - all_columns, column_types, csv_value, exportable_columns, folded_column_lookup, - is_importable_system_column, is_system_column, - }, + schema::{column_types, csv_value, importable_columns, required_columns}, }, loader::load_page, - state::ImportForm, + prepare::{ + Prepared, SourceMode, canonical_csv, prepare, read_mapping, read_source, suggest_mapping, + template_csv, + }, + state::{ + ImportForm, MappingRow, MappingStep, PreviewStep, Step, TemplateRow, TemplateStep, + }, ui, }; -struct ImportTable { - name: String, +/// How many prepared rows the preview shows. +const PREVIEW_ROWS: usize = 20; + +/// The table an import writes into, as the server currently declares it. +/// +/// Loaded again at every step rather than carried in the form. The destination +/// list is what makes a mapping mean anything, so it comes from the table each +/// time: a column dropped or renamed while the form sat open turns into a +/// refusal on the next click instead of a value written somewhere else. +struct Destination { + table_name: String, + /// The columns a mapping may point at. + columns: Vec, + /// Of those, the ones declared `NOT NULL`. + required: Vec, types: HashMap, - /// The columns an import may write, matched exactly. A header is an - /// identifier the file states, not something to be interpreted: it either - /// is the column's name or it is not. - columns: HashSet, - /// The same columns keyed by their lowercased name. Never used to accept a - /// header — only to tell the user that the header they wrote is one - /// "Normalize headers" would turn into a real column. - by_folded_name: HashMap, -} - -impl ImportTable { - /// `columns` are the ones a header may name; `known` is every column a row - /// can arrive with, the system ones included, which is what the hint in a - /// refusal is drawn from — a file written `ID` should be told about `id` - /// rather than told that no such column exists. - fn new( - name: String, - columns: Vec, - known: Vec, - types: HashMap, - ) -> Self { - Self { - name, - types, - by_folded_name: folded_column_lookup(&known), - columns: columns.into_iter().collect(), - } - } - - fn has_column(&self, header: &str) -> bool { - self.columns.contains(header) - } - - /// The column a header would name once its spacing and capitalisation were - /// normalized, for the error message that offers to do exactly that. - fn near_match(&self, header: &str) -> Option<&str> { - self.by_folded_name - .get(&header.trim().to_lowercase()) - .map(String::as_str) - } } +/// GET /admin/import pub(crate) async fn import_page(State(state): State, headers: HeaderMap) -> Response { - render_loaded(&headers, load_page(state, &headers, ImportForm::default(), None).await) + match load_page(state, &headers, ImportForm::default(), Step::Source).await { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(error) => load_error(&headers, error), + } } -pub(crate) async fn import_csv( +/// POST /admin/import/source — back to the first step, with the answers the +/// user already gave still in the fields. +pub(crate) async fn source_step( State(state): State, headers: HeaderMap, Form(form): Form, @@ -90,277 +71,15 @@ pub(crate) async fn import_csv( if let Some(rejection) = reject_cross_site(&headers) { return rejection; } - let submitted = form.clone(); - let page = match load_page(state.clone(), &headers, submitted.clone(), None).await { - Ok(page) => page, - Err(error) => return load_error(&headers, error), - }; - let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) { - Ok(targets) => targets, - Err(message) => return reject(&headers, message), - }; - let Some(profile) = page.catalog.profiles.iter().find(|profile| profile.name == profile_name) else { - return reject( - &headers, - tr!(Locale::from_headers(&headers), "import-err-unknown-profile"), - ); - }; - if table_names.iter().any(|name| !profile.tables.contains(name)) { - return reject( - &headers, - tr!( - Locale::from_headers(&headers), - "import-err-tables-not-in-profile" - ), - ); - } - let rows = match parse_csv(Locale::from_headers(&headers), &form.csv_data) { - Ok(rows) => rows, - Err(message) => return reject(&headers, message), - }; - let (table_headers, columns, data_rows) = - match split_headers(Locale::from_headers(&headers), rows, &table_names) { - Ok(parts) => parts, - Err(message) => return reject(&headers, message), - }; - - let mut tables = Vec::new(); - for table_name in &table_names { - let request = GetTableStructureRequest { - profile_name: profile_name.clone(), - table_names: vec![table_name.clone()], - }; - let mut structures = state.structures.clone(); - let structure = match structures.get_table_structure(match authenticated_request(&headers, request) { - Ok(request) => request, - Err(_) => return Redirect::to("/login").into_response(), - }).await { - Ok(response) => response.into_inner().table_structures.remove(table_name), - Err(error) => return grpc_error(&headers, &error), - }; - let Some(structure) = structure else { - return unavailable( - &headers, - tr!( - Locale::from_headers(&headers), - "import-err-missing-structure" - ), - ); - }; - tables.push(ImportTable::new( - table_name.clone(), - exportable_columns(&structure), - all_columns(&structure), - column_types(&structure), - )); - } - - let file_has_system_columns = form.import_system_columns(); - let system_columns_in_file = system_columns_of(&columns); - if let Err(message) = check_system_columns( - Locale::from_headers(&headers), - file_has_system_columns, - &system_columns_in_file, - ) { - return reject(&headers, message); - } - let server_assigned = server_assigned_of(&system_columns_in_file); - let mut inserted = 0usize; - for table in &tables { - let belongs_to_table = |index: usize| { - table_headers.as_ref().map_or(table_names.len() == 1, |headers| { - headers.get(index).is_some_and(|name| name == &table.name) - }) - }; - let positions = columns - .iter() - .enumerate() - .filter(|(index, column)| { - belongs_to_table(*index) - && (table.has_column(column) - || (file_has_system_columns && is_importable_system_column(column))) - }) - .map(|(index, column)| (index, column.clone())) - .collect::>(); - if positions.is_empty() { - return reject( - &headers, - tr!( - Locale::from_headers(&headers), - "import-err-no-importable-columns", - "table" => table.name.clone(), - ), - ); - } - for (index, column) in columns.iter().enumerate() { - let belongs = belongs_to_table(index); - if belongs && is_system_column(column) { - continue; - } - if belongs && !table.has_column(column) { - // A header that is only a normalization away from a real column - // says so, instead of accusing a name the user can see in the - // table of not existing. - let message = match table.near_match(column) { - Some(near) => tr!( - Locale::from_headers(&headers), - "import-err-column-near-match", - "header" => column.clone(), - "column" => near.to_string(), - "table" => table.name.clone(), - ), - None => tr!( - Locale::from_headers(&headers), - "import-err-column-not-importable", - "column" => column.clone(), - "table" => table.name.clone(), - ), - }; - return reject(&headers, message); - } - } - let converted = match data_rows - .iter() - .enumerate() - .map(|(row_index, row)| { - row_for_table(Locale::from_headers(&headers), table, &positions, row).map_err( - |error| { - tr!( - Locale::from_headers(&headers), - "import-err-csv-row", - "row" => (row_index + 2) as i64, - "error" => error, - ) - }, - ) - }) - .collect::, _>>() - { - Ok(rows) => rows, - Err(message) => return reject(&headers, message), - }; - for chunk in converted.chunks(1_000) { - let request = PostTableDataBulkRequest { - profile_name: profile_name.clone(), - table_name: table.name.clone(), - rows: chunk.to_vec(), - }; - let mut data = state.tables_data.clone(); - let response = match data.post_table_data_bulk(match authenticated_request(&headers, request) { - Ok(request) => request, - Err(_) => return Redirect::to("/login").into_response(), - }).await { - Ok(response) => response.into_inner(), - Err(error) => return grpc_error(&headers, &error), - }; - inserted += response.responses.iter().filter(|row| row.inserted_id > 0).count(); - } - } - Html(ui::render_success( - Locale::from_headers(&headers), - inserted, - data_rows.len(), - tables.len(), - &server_assigned, - )) - .into_response() + render_step(state, &headers, form, Step::Source).await } -/// The system columns a header row carries, in one order regardless of the -/// order they appear in. -fn system_columns_of(columns: &[String]) -> Vec { - let mut found = columns - .iter() - .filter(|column| is_system_column(column)) - .cloned() - .collect::>(); - found.sort(); - found.dedup(); - found -} - -/// The ones the server assigns and will not take from a client. `deleted` is -/// not among them: it is written from the file, which is what makes a ticked -/// import a copy of what was exported rather than a fresh set of live rows. -fn server_assigned_of(system_columns: &[String]) -> Vec { - system_columns - .iter() - .filter(|column| !is_importable_system_column(column)) - .cloned() - .collect() -} - -/// The checkbox says what kind of file this is, and the file has to agree. +/// POST /admin/import/prepare — the second step: the mapping table for a file, +/// or the column chooser for a template. /// -/// Unticked means plain data, so a server-managed column in it is a mismatch -/// said out loud rather than dropped on the user's behalf. Ticked means an -/// export taken with the system columns in it, which loads as it stands. -fn check_system_columns( - locale: Locale, - file_has_system_columns: bool, - system_columns: &[String], -) -> Result<(), String> { - if !file_has_system_columns && !system_columns.is_empty() { - return Err(tr!( - locale, - "import-err-system-columns-present", - "columns" => system_columns.join(", "), - )); - } - Ok(()) -} - -/// A header cell is an identifier the file states, so it is checked rather -/// than tidied: a name with spaces around it is a different name, and the file -/// is refused with the cell quoted so the difference is visible. "Normalize -/// headers" is how a user asks for the tidying, and it rewrites the CSV in -/// front of them instead of happening in here. -fn reject_padded_headers(locale: Locale, row: &[String]) -> Result<(), String> { - match row.iter().find(|cell| cell.trim() != cell.as_str()) { - Some(cell) => Err(tr!( - locale, - "import-err-header-padded", - "header" => cell.clone(), - "trimmed" => cell.trim().to_string(), - )), - None => Ok(()), - } -} - -/// Two headers naming the same column of the same table make the file -/// ambiguous: whichever one is read second decides the value, silently. In a -/// multi-table file the pair is (table, column), since two tables may each have -/// a `name` column. -fn reject_duplicate_headers( - locale: Locale, - table_headers: Option<&[String]>, - columns: &[String], -) -> Result<(), String> { - let mut seen = HashSet::new(); - for (index, column) in columns.iter().enumerate() { - let table = table_headers.and_then(|headers| headers.get(index)).cloned(); - if !seen.insert((table.clone(), column.clone())) { - return Err(tr!( - locale, - "import-err-duplicate-header", - "header" => column.clone(), - "table" => table.unwrap_or_default(), - )); - } - } - Ok(()) -} - -/// POST /admin/import/normalize — rewrites the header rows and hands the CSV -/// back for the user to look at. Nothing is imported. -/// -/// This is the one place allowed to change what the user pasted, and it is -/// explicit: the button says what it does, the result goes back into the -/// textarea, and the import that follows reads that text as strictly as it -/// reads any other. A header keeps its own spelling unless it matches a column -/// exactly once the spacing and capitalisation are taken out, so nothing is -/// renamed on a guess. -pub(crate) async fn normalize_headers( +/// Also where the template's move buttons land, because reordering is a change +/// to the same step rather than a step of its own. +pub(crate) async fn prepare_step( State(state): State, headers: HeaderMap, Form(form): Form, @@ -369,167 +88,409 @@ pub(crate) async fn normalize_headers( return rejection; } let locale = Locale::from_headers(&headers); - let (profile_name, table_names) = match form.targets(locale) { - Ok(targets) => targets, + let destination = match destination(state.clone(), &headers, &form).await { + Ok(destination) => destination, + Err(response) => return response, + }; + let mode = match form.mode(locale) { + Ok(mode) => mode, Err(message) => return reject(&headers, message), }; - let mut rows = match parse_csv(locale, &form.csv_data) { + + let step = match mode { + SourceMode::Template => template_step(&form, &destination), + SourceMode::Header | SourceMode::Data => { + let source = match read_source(locale, &form.csv_data, mode) { + Ok(source) => source, + Err(message) => return reject(&headers, message), + }; + // A mapping that does not cover this file was built against a + // different one — the user edited the CSV and came back. Start it + // over rather than lining up positions that no longer correspond. + let chosen = if form.mapping.len() == source.width { + form.mapping.clone() + } else { + suggest_mapping(&source, &destination.columns) + }; + let examples = source.examples(); + Step::Mapping(MappingStep { + table_name: destination.table_name.clone(), + columns: destination.columns.clone(), + rows: (0..source.width) + .map(|position| MappingRow { + position: position + 1, + source_name: source.source_name(position), + example: examples.get(position).cloned().unwrap_or_default(), + target: chosen.get(position).cloned().unwrap_or_default(), + }) + .collect(), + source_rows: source.rows.len(), + has_source_names: source.header.is_some(), + }) + } + }; + render_step(state, &headers, form, step).await +} + +/// POST /admin/import/preview — the prepared import, before anything is sent. +pub(crate) async fn preview_step( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let locale = Locale::from_headers(&headers); + let (destination, prepared) = match prepared(state.clone(), &headers, &form).await { + Ok(parts) => parts, + Err(response) => return response, + }; + + let source_names = source_names(locale, &form); + let step = Step::Preview(PreviewStep { + table_name: destination.table_name.clone(), + columns: prepared.columns.clone(), + rows: prepared.preview_rows(PREVIEW_ROWS).to_vec(), + hidden_rows: prepared.hidden_rows(PREVIEW_ROWS), + source_rows: prepared.row_count(), + ignored: prepared + .ignored + .iter() + .map(|position| match source_names.get(position - 1) { + Some(name) if !name.is_empty() => format!("{position} ({name})"), + _ => position.to_string(), + }) + .collect(), + missing_required: destination + .required + .iter() + .filter(|column| !prepared.columns.contains(column)) + .cloned() + .collect(), + omitted: prepared.omitted.clone(), + csv: canonical_csv(&prepared), + }); + render_step(state, &headers, form, step).await +} + +/// POST /admin/import — the prepared rows, converted and inserted. +/// +/// From here on nothing about mapping exists any more: what is sent is the +/// canonical import, and the server decides types, validations, scripts, links +/// and permissions exactly as it does for any other insert. +pub(crate) async fn import_csv( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let locale = Locale::from_headers(&headers); + let (profile_name, _) = match form.target(locale) { + Ok(target) => target, + Err(message) => return reject(&headers, message), + }; + let (destination, prepared) = match prepared(state.clone(), &headers, &form).await { + Ok(parts) => parts, + Err(response) => return response, + }; + + let converted = match prepared + .rows + .iter() + .enumerate() + .map(|(index, row)| { + bulk_row(locale, &destination, &prepared.columns, row).map_err(|error| { + tr!( + locale, + "import-err-csv-row", + "row" => (index + 1) as i64, + "error" => error, + ) + }) + }) + .collect::, _>>() + { Ok(rows) => rows, Err(message) => return reject(&headers, message), }; - // The columns of every selected table, so a header can be rewritten into - // the spelling the table actually declares. - let mut lookups = Vec::new(); - for table_name in &table_names { - let request = GetTableStructureRequest { + let mut inserted = 0usize; + for chunk in converted.chunks(1_000) { + let request = PostTableDataBulkRequest { profile_name: profile_name.clone(), - table_names: vec![table_name.clone()], + table_name: destination.table_name.clone(), + rows: chunk.to_vec(), }; - let mut structures = state.structures.clone(); - let structure = match structures - .get_table_structure(match authenticated_request(&headers, request) { + let mut data = state.tables_data.clone(); + let response = match data + .post_table_data_bulk(match authenticated_request(&headers, request) { Ok(request) => request, Err(_) => return Redirect::to("/login").into_response(), }) .await { - Ok(response) => response.into_inner().table_structures.remove(table_name), + Ok(response) => response.into_inner(), Err(error) => return grpc_error(&headers, &error), }; - let Some(structure) = structure else { - return unavailable(&headers, tr!(locale, "import-err-missing-structure")); - }; - // Every column a row can arrive with, the system ones included: a file - // exported with them has `ID` and `Row_Revision` headers to normalize - // too, and leaving those to fall through would send the user back to a - // strict refusal the button was supposed to settle. - lookups.push(( - table_name.clone(), - folded_column_lookup(&all_columns(&structure)), + inserted += response + .responses + .iter() + .filter(|row| row.inserted_id > 0) + .count(); + } + + Html(ui::render_success( + locale, + inserted, + prepared.row_count(), + &destination.table_name, + )) + .into_response() +} + +/// POST /admin/import/prepared.csv — the same file the import would read, to +/// look at elsewhere and bring back later. +pub(crate) async fn download_prepared( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let (destination, prepared) = match prepared(state, &headers, &form).await { + Ok(parts) => parts, + Err(response) => return response, + }; + attachment( + canonical_csv(&prepared), + &format!("{}_prepared.csv", destination.table_name), + ) +} + +/// POST /admin/import/template.csv — the chosen columns as a header row, and +/// nothing else. +pub(crate) async fn download_template( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let locale = Locale::from_headers(&headers); + let destination = match destination(state, &headers, &form).await { + Ok(destination) => destination, + Err(response) => return response, + }; + let columns = match template_selection(locale, &form, &destination) { + Ok(columns) => columns, + Err(message) => return reject(&headers, message), + }; + attachment( + template_csv(&columns), + &format!("{}_template.csv", destination.table_name), + ) +} + +/// The table this import writes into, once the form's scope and table have been +/// checked against what the caller may actually import into. +async fn destination( + state: AppState, + headers: &HeaderMap, + form: &ImportForm, +) -> Result { + let locale = Locale::from_headers(headers); + let page = load_page(state.clone(), headers, form.clone(), Step::Source) + .await + .map_err(|error| load_error(headers, error))?; + let (profile_name, table_name) = form + .target(locale) + .map_err(|message| reject(headers, message))?; + let profile = page + .catalog + .profiles + .iter() + .find(|profile| profile.name == profile_name) + .ok_or_else(|| reject(headers, tr!(locale, "import-err-unknown-profile")))?; + if !profile.tables.contains(&table_name) { + return Err(reject( + headers, + tr!(locale, "import-err-tables-not-in-profile"), )); } - let multi_table = table_names.len() > 1; - let table_row = multi_table.then(|| rows.first().cloned()).flatten(); - if multi_table && rows.len() < 2 { - return reject(&headers, tr!(locale, "import-err-multi-headers")); - } - let column_row_index = usize::from(multi_table); + let request = GetTableStructureRequest { + profile_name, + table_names: vec![table_name.clone()], + }; + let mut structures = state.structures.clone(); + let structure = structures + .get_table_structure( + authenticated_request(headers, request) + .map_err(|_| Redirect::to("/login").into_response())?, + ) + .await + .map_err(|error| grpc_error(headers, &error))? + .into_inner() + .table_structures + .remove(&table_name) + .ok_or_else(|| unavailable(headers, tr!(locale, "import-err-missing-structure")))?; - // The table-name row first: a column header is looked up in the table its - // own cell names, so that row has to be settled before the other one. - let table_row = table_row.map(|row| { - row.iter() - .map(|cell| normalized_name(cell, table_names.iter().map(String::as_str))) - .collect::>() - }); - if let Some(row) = table_row.clone() { - rows[0] = row; + let columns = importable_columns(&structure); + if columns.is_empty() { + return Err(reject( + headers, + tr!( + locale, + "import-err-no-importable-columns", + "table" => table_name.clone(), + ), + )); } - if let Some(columns) = rows.get(column_row_index).cloned() { - rows[column_row_index] = columns + Ok(Destination { + table_name, + required: required_columns(&structure), + types: column_types(&structure), + columns, + }) +} + +/// The prepared import: the source read as the user said it is laid out, the +/// mapping checked against the table as it stands, and the two applied. +/// +/// Shared by preview, download and import so that all three are the same file +/// — looking at it and importing it cannot disagree about what it contains. +async fn prepared( + state: AppState, + headers: &HeaderMap, + form: &ImportForm, +) -> Result<(Destination, Prepared), Response> { + let locale = Locale::from_headers(headers); + let destination = destination(state, headers, form).await?; + let mode = form.mode(locale).map_err(|message| reject(headers, message))?; + if !mode.reads_a_file() { + return Err(reject( + headers, + tr!(locale, "import-err-template-has-no-source"), + )); + } + let source = read_source(locale, &form.csv_data, mode) + .map_err(|message| reject(headers, message))?; + let mapping = read_mapping(locale, &form.mapping, source.width, &destination.columns) + .map_err(|message| reject(headers, message))?; + let prepared = prepare(&mapping, &source, &destination.columns); + Ok((destination, prepared)) +} + +/// The column chooser, with the arrangement the form carries and whatever move +/// button was pressed applied to it. +fn template_step(form: &ImportForm, destination: &Destination) -> Step { + let mut order = arranged_columns(form, destination); + if let (Some(action), Some(index)) = (form.action.as_deref(), form.index) { + let swap_with = match action { + "up" if index > 0 => Some(index - 1), + "down" if index + 1 < order.len() => Some(index + 1), + _ => None, + }; + if let Some(other) = swap_with { + order.swap(index, other); + } + } + + let chosen = order + .iter() + .filter(|column| form.template_columns.contains(column)) + .cloned() + .collect::>(); + let last = order.len().saturating_sub(1); + Step::Template(TemplateStep { + table_name: destination.table_name.clone(), + rows: order .iter() .enumerate() - .map(|(index, cell)| { - let table = table_row - .as_ref() - .and_then(|row| row.get(index)) - .cloned() - .unwrap_or_else(|| table_names.first().cloned().unwrap_or_default()); - lookups - .iter() - .find(|(name, _)| name == &table) - .and_then(|(_, lookup)| lookup.get(&cell.trim().to_lowercase())) - .cloned() - .unwrap_or_else(|| cell.trim().to_string()) + .map(|(index, name)| TemplateRow { + index, + chosen: form.template_columns.contains(name), + required: destination.required.contains(name), + name: name.clone(), + first: index == 0, + last: index == last, }) - .collect(); - } - - let mut csv = String::new(); - for row in &rows { - write_record(&mut csv, row); - } - let changed = csv != form.csv_data; - let page = match load_page( - state, - &headers, - ImportForm { - csv_data: csv, - ..form + .collect(), + header: if chosen.is_empty() { + String::new() + } else { + template_csv(&chosen) }, - None, - ) - .await - { - Ok(page) => page, - Err(error) => return load_error(&headers, error), - }; - Html(ui::render_normalized(&page, changed)).into_response() + chosen: chosen.len(), + }) } -/// A cell rewritten to the one candidate it matches once spacing and -/// capitalisation are set aside, or trimmed and left alone when it matches none. -fn normalized_name<'a>(cell: &str, candidates: impl Iterator) -> String { - let folded = cell.trim().to_lowercase(); - candidates - .filter(|candidate| candidate.to_lowercase() == folded) - .map(str::to_string) - .next() - .unwrap_or_else(|| cell.trim().to_string()) -} - -fn split_headers( - locale: Locale, - rows: Vec>, - tables: &[String], -) -> Result<(Option>, Vec, Vec>), String> { - if tables.len() > 1 { - if rows.len() < 2 { - return Err(tr!(locale, "import-err-multi-headers")); +/// The destination columns in the order the template step has them arranged. +/// +/// The table's own order until the user moves something, and thereafter what +/// the form carries — minus anything the table no longer has, plus anything it +/// has gained, so a table changed mid-arrangement neither drops a column from +/// the chooser nor offers one that is gone. +fn arranged_columns(form: &ImportForm, destination: &Destination) -> Vec { + let mut order = form + .template_order + .iter() + .filter(|column| destination.columns.contains(column)) + .cloned() + .collect::>(); + order.dedup(); + for column in &destination.columns { + if !order.contains(column) { + order.push(column.clone()); } - let table_headers = rows[0].clone(); - reject_padded_headers(locale, &table_headers)?; - if table_headers.iter().any(|name| !tables.contains(name)) { - return Err(tr!(locale, "import-err-first-row")); - } - let columns = rows[1].clone(); - reject_padded_headers(locale, &columns)?; - if columns.len() != table_headers.len() { - return Err(tr!(locale, "import-err-header-lengths")); - } - reject_duplicate_headers(locale, Some(&table_headers), &columns)?; - validate_width(locale, &rows[2..], columns.len())?; - Ok((Some(table_headers), columns, rows[2..].to_vec())) - } else { - let columns = rows[0].clone(); - reject_padded_headers(locale, &columns)?; - reject_duplicate_headers(locale, None, &columns)?; - validate_width(locale, &rows[1..], columns.len())?; - Ok((None, columns, rows[1..].to_vec())) } + order } -fn validate_width(locale: Locale, rows: &[Vec], width: usize) -> Result<(), String> { - if rows.iter().any(|row| row.len() != width) { - Err(tr!(locale, "import-err-row-width")) - } else if rows.is_empty() { - Err(tr!(locale, "import-err-no-data-rows")) - } else { - Ok(()) - } -} - -fn row_for_table( +/// The template's columns, in the arranged order, checked for being answered. +fn template_selection( locale: Locale, - table: &ImportTable, - positions: &[(usize, String)], + form: &ImportForm, + destination: &Destination, +) -> Result, String> { + let chosen = arranged_columns(form, destination) + .into_iter() + .filter(|column| form.template_columns.contains(column)) + .collect::>(); + if chosen.is_empty() { + return Err(tr!(locale, "import-err-template-empty")); + } + Ok(chosen) +} + +/// The source's own names for its positions, for labelling the ignored ones in +/// the summary. Best effort: a source that cannot be read at all has already +/// been refused elsewhere. +fn source_names(locale: Locale, form: &ImportForm) -> Vec { + form.mode(locale) + .ok() + .filter(|mode| *mode == SourceMode::Header) + .and_then(|mode| read_source(locale, &form.csv_data, mode).ok()) + .and_then(|source| source.header) + .unwrap_or_default() +} + +/// One prepared row as the bulk insert takes it: values under their destination +/// names, converted to the column's type. +fn bulk_row( + locale: Locale, + destination: &Destination, + columns: &[String], row: &[String], ) -> Result { let mut data = HashMap::new(); - for (index, column) in positions { - let data_type = table.types.get(column).ok_or_else(|| { + for (index, column) in columns.iter().enumerate() { + let data_type = destination.types.get(column).ok_or_else(|| { tr!( locale, "import-err-missing-type", @@ -538,37 +499,53 @@ fn row_for_table( })?; let value = csv_value( locale, - row.get(*index).map(String::as_str).unwrap_or_default(), + row.get(index).map(String::as_str).unwrap_or_default(), data_type, )?; data.insert(column.clone(), value); } - Ok(PostTableDataBulkRow { - data, - }) + Ok(PostTableDataBulkRow { data }) } -fn render_loaded(headers: &HeaderMap, result: Result) -> Response { - match result { - Ok(page) => Html(ui::render_page(&page)).into_response(), +async fn render_step( + state: AppState, + headers: &HeaderMap, + form: ImportForm, + step: Step, +) -> Response { + match load_page(state, headers, form, step).await { + Ok(page) => Html(ui::render_step(&page)).into_response(), Err(error) => load_error(headers, error), } } -/// A CSV this crate rejected before any of it reached the backend: an unknown -/// profile, a column the table has no place for, an unparseable cell. The -/// form's `hx-target` is `#submission-status`, so the answer is the alert -/// fragment — re-rendering the page here sent a whole `` document to be -/// swapped into a status block. +fn attachment(csv: String, filename: &str) -> Response { + let mut response = csv.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/csv; charset=utf-8"), + ); + if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) { + response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + response +} + +/// Something this crate refused before any of it reached the backend: an +/// unknown scope, a mapping that does not fit the file, a cell no type accepts. +/// The buttons target `#submission-status`, so the answer is the alert fragment +/// rather than a whole page. fn reject(headers: &HeaderMap, message: String) -> Response { crate::ui::FormError::Rejected(message) .into_response(Locale::from_headers(headers), ui::render_error) } -/// A gRPC failure, classified by its code. An import that the server refused -/// because of what was *in* the CSV — a script-consistency mismatch, a -/// validation pattern the value does not satisfy — is the user's to fix and -/// answers 422; only an actually unreachable backend answers 502. +/// A gRPC failure, classified by its code. An import the server refused because +/// of what was *in* the rows — a script-consistency mismatch, a validation +/// pattern a value does not satisfy — is the user's to fix and answers 422; +/// only an actually unreachable backend answers 502. fn grpc_error(headers: &HeaderMap, error: &tonic::Status) -> Response { crate::ui::FormError::from_status(error) .into_response(Locale::from_headers(headers), ui::render_error) @@ -582,8 +559,7 @@ fn load_error(headers: &HeaderMap, error: LoadError) -> Response { } /// The backend answered, but not with what the import needs — a table the -/// catalogue lists and the structure service does not know. Nothing the user -/// can fix from the form. +/// catalogue lists and the structure service does not know. fn unavailable(headers: &HeaderMap, message: String) -> Response { crate::ui::FormError::Unavailable(message) .into_response(Locale::from_headers(headers), ui::render_error) @@ -593,205 +569,134 @@ fn unavailable(headers: &HeaderMap, message: String) -> Response { mod tests { use super::*; - #[test] - fn splits_single_table_csv_headers() { - let rows = vec![ - vec!["name".to_string(), "amount".to_string()], - vec!["One".to_string(), "10".to_string()], - ]; - let (tables, columns, data) = - split_headers(Locale::default(), rows, &["invoice".to_string()]).unwrap(); - assert!(tables.is_none()); - assert_eq!(columns, vec!["name", "amount"]); - assert_eq!(data.len(), 1); + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() } - #[test] - fn splits_multi_table_csv_headers() { - let rows = vec![ - vec!["invoice".to_string(), "customer".to_string()], - vec!["number".to_string(), "name".to_string()], - vec!["I-1".to_string(), "Acme".to_string()], - ]; - let targets = vec!["invoice".to_string(), "customer".to_string()]; - let (tables, columns, data) = split_headers(Locale::default(), rows, &targets).unwrap(); - assert_eq!(tables.unwrap(), targets); - assert_eq!(columns, vec!["number", "name"]); - assert_eq!(data.len(), 1); - } - - fn table(columns: &[&str]) -> ImportTable { - let columns: Vec = columns.iter().map(|column| column.to_string()).collect(); - ImportTable::new( - "invoice".to_string(), - columns.clone(), - columns, - HashMap::new(), - ) - } - - /// The byte-order mark is encoding metadata, so decoding consumes it and - /// the first header is the name the user typed. Everything else about the - /// header is left exactly as it arrived. - #[test] - fn the_byte_order_mark_is_decoded_away_and_nothing_else_is() { - let rows = parse_csv( - Locale::default(), - "\u{feff}\"label\",\"amount\"\n\"Acme\",\"10\"\n", - ) - .unwrap(); - assert_eq!(rows[0], vec!["label", "amount"]); - - let rows = parse_csv( - Locale::default(), - "\"LABEL\",\" amount \"\n\"Acme\",\"10\"\n", - ) - .unwrap(); - assert_eq!(rows[0], vec!["LABEL", " amount "]); - } - - /// A padded header is a different name, and the file says so. It is - /// refused with the cell quoted, because the difference between `amount` - /// and ` amount` is invisible everywhere else. - #[test] - fn a_padded_header_is_refused_and_the_message_shows_the_cell() { - let rows = vec![ - vec!["label".to_string(), " amount".to_string()], - vec!["Acme".to_string(), "10".to_string()], - ]; - let error = split_headers(Locale::default(), rows, &["invoice".to_string()]) - .expect_err("a padded header is not the column's name"); - assert!(error.contains(" amount"), "{error}"); - } - - /// Two headers naming one column let the second silently win. The file is - /// refused instead. - #[test] - fn a_repeated_header_is_refused() { - let rows = vec![ - vec!["label".to_string(), "label".to_string()], - vec!["Acme".to_string(), "Other".to_string()], - ]; - let error = split_headers(Locale::default(), rows, &["invoice".to_string()]) - .expect_err("one column cannot be given twice"); - assert!(error.contains("label"), "{error}"); - - // Two tables may each have their own `name`, so the pair is what has - // to be unique. - let rows = vec![ - vec!["invoice".to_string(), "customer".to_string()], - vec!["name".to_string(), "name".to_string()], - vec!["I-1".to_string(), "Acme".to_string()], - ]; - let targets = vec!["invoice".to_string(), "customer".to_string()]; - assert!(split_headers(Locale::default(), rows, &targets).is_ok()); - } - - /// Matching stays exact. The folded lookup exists only so the refusal can - /// name the column the header nearly is, and point at the button that - /// would rewrite it. - #[test] - fn a_near_miss_is_refused_but_recognised() { - let table = table(&["label", "amount"]); - - assert!(table.has_column("label")); - assert!(!table.has_column("LABEL")); - assert!(!table.has_column(" label")); - - assert_eq!(table.near_match("LABEL"), Some("label")); - assert_eq!(table.near_match(" amount "), Some("amount")); - assert_eq!(table.near_match("total"), None); - } - - /// A table-header row names tables exactly too, padding included. - #[test] - fn the_table_header_row_is_checked_as_strictly_as_the_columns() { - let targets = vec!["invoice".to_string(), "customer".to_string()]; - - let rows = vec![ - vec![" invoice".to_string(), "customer".to_string()], - vec!["number".to_string(), "name".to_string()], - vec!["I-1".to_string(), "Acme".to_string()], - ]; - assert!(split_headers(Locale::default(), rows, &targets).is_err()); - - let rows = vec![ - vec!["Invoice".to_string(), "customer".to_string()], - vec!["number".to_string(), "name".to_string()], - vec!["I-1".to_string(), "Acme".to_string()], - ]; - assert!(split_headers(Locale::default(), rows, &targets).is_err()); - } - - /// Which system columns the checkbox can actually deliver. `deleted` is - /// the one an insert takes; the rest are the database's, so ticking the box - /// on a file carrying them is an error rather than a silent drop. Mirrors - /// `server/src/tables_data/operations/post_table_data.rs`. - #[test] - fn only_deleted_can_be_imported_of_the_system_columns() { - assert!(is_importable_system_column("deleted")); - for column in ["id", "row_revision", "created_at"] { - assert!(is_system_column(column), "{column}"); - assert!(!is_importable_system_column(column), "{column}"); + fn destination() -> Destination { + Destination { + table_name: "customers".to_string(), + columns: strings(&["name", "company_number", "active"]), + required: strings(&["name"]), + types: HashMap::new(), } - // And it is a system column, so the unticked box still leaves it to - // the server rather than treating it as a user column. - assert!(is_system_column("deleted")); } - /// The checkbox and the file have to agree, and the refusal names what is - /// in the file so it can be removed or the box ticked. - #[test] - fn a_plain_import_refuses_a_file_that_carries_system_columns() { - let columns = vec![ - "id".to_string(), - "label".to_string(), - "deleted".to_string(), - "id".to_string(), - ]; - let found = system_columns_of(&columns); - assert_eq!(found, vec!["deleted".to_string(), "id".to_string()]); - - let error = check_system_columns(Locale::default(), false, &found) - .expect_err("a plain file may not carry system columns"); - assert!(error.contains("deleted") && error.contains("id"), "{error}"); - - // Ticked, the same file is exactly what was asked for. - assert!(check_system_columns(Locale::default(), true, &found).is_ok()); - // And a file without them is fine either way. - assert!(system_columns_of(&["label".to_string()]).is_empty()); - assert!(check_system_columns(Locale::default(), false, &[]).is_ok()); + fn template_rows(step: &Step) -> Vec<(String, bool)> { + match step { + Step::Template(step) => step + .rows + .iter() + .map(|row| (row.name.clone(), row.chosen)) + .collect(), + _ => panic!("not the template step"), + } } - /// `deleted` is written from the file, so it is not among the columns the - /// result reports as the server's. + /// The chooser starts in the table's own order, with nothing ticked. #[test] - fn only_the_columns_the_server_assigns_are_reported() { - let found = system_columns_of(&[ - "deleted".to_string(), - "id".to_string(), - "row_revision".to_string(), - "created_at".to_string(), - ]); + fn the_template_starts_in_table_order() { + let step = template_step(&ImportForm::default(), &destination()); assert_eq!( - server_assigned_of(&found), + template_rows(&step), vec![ - "created_at".to_string(), - "id".to_string(), - "row_revision".to_string() + ("name".to_string(), false), + ("company_number".to_string(), false), + ("active".to_string(), false), ] ); } - /// The normalizer rewrites a cell only when exactly one candidate matches - /// it once spacing and capitalisation are set aside. + /// Moving a row rearranges the chooser, and the generated header follows + /// the arrangement rather than the table. #[test] - fn normalizing_rewrites_a_recognised_name_and_leaves_the_rest_alone() { - let tables = ["invoice", "customer"]; - assert_eq!(normalized_name(" Invoice ", tables.into_iter()), "invoice"); - assert_eq!(normalized_name("orders", tables.into_iter()), "orders"); - // An unknown name still loses its padding, so the strict import that - // follows complains about the name rather than the spaces. - assert_eq!(normalized_name(" orders ", tables.into_iter()), "orders"); + fn moving_a_row_rearranges_the_generated_header() { + let form = ImportForm { + template_order: strings(&["name", "company_number", "active"]), + template_columns: strings(&["name", "active"]), + action: Some("down".to_string()), + index: Some(0), + ..ImportForm::default() + }; + let step = template_step(&form, &destination()); + assert_eq!( + template_rows(&step) + .into_iter() + .map(|(name, _)| name) + .collect::>(), + strings(&["company_number", "name", "active"]) + ); + match step { + Step::Template(step) => { + assert_eq!(step.header, "\"name\",\"active\"\n"); + assert_eq!(step.chosen, 2); + } + _ => panic!("not the template step"), + } + } + + /// A move off either end does nothing rather than wrapping around. + #[test] + fn a_move_past_the_end_is_ignored() { + for (action, index) in [("up", 0), ("down", 2)] { + let form = ImportForm { + template_order: strings(&["name", "company_number", "active"]), + action: Some(action.to_string()), + index: Some(index), + ..ImportForm::default() + }; + assert_eq!( + template_rows(&template_step(&form, &destination())) + .into_iter() + .map(|(name, _)| name) + .collect::>(), + strings(&["name", "company_number", "active"]) + ); + } + } + + /// A table changed while the chooser was open: the column it lost leaves + /// the arrangement, and the one it gained joins the end of it. + #[test] + fn the_arrangement_follows_the_table_it_is_for() { + let form = ImportForm { + template_order: strings(&["active", "gone", "name"]), + ..ImportForm::default() + }; + assert_eq!( + arranged_columns(&form, &destination()), + strings(&["active", "name", "company_number"]) + ); + } + + /// Generating a template with nothing ticked is refused rather than + /// producing an empty header. + #[test] + fn a_template_needs_at_least_one_column() { + assert!( + template_selection(Locale::default(), &ImportForm::default(), &destination()).is_err() + ); + } + + /// The ignored positions are labelled with the source's own names when the + /// file gave any, which is what makes the summary readable. + #[test] + fn a_header_files_names_are_read_back_for_the_summary() { + let form = ImportForm { + source_mode: "header".to_string(), + csv_data: "\"Company\",\"Internal note\"\n\"Acme\",\"old\"\n".to_string(), + ..ImportForm::default() + }; + assert_eq!( + source_names(Locale::default(), &form), + strings(&["Company", "Internal note"]) + ); + + // A data-only file has none, and the summary falls back to positions. + let form = ImportForm { + source_mode: "data".to_string(), + ..form + }; + assert!(source_names(Locale::default(), &form).is_empty()); } } diff --git a/web/src/pages/import_export/import/mod.rs b/web/src/pages/import_export/import/mod.rs index b690c147..a35d9760 100644 --- a/web/src/pages/import_export/import/mod.rs +++ b/web/src/pages/import_export/import/mod.rs @@ -1,16 +1,29 @@ mod loader; mod logic; +mod prepare; mod state; mod ui; -use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}}; +use axum::{ + Router, + extract::DefaultBodyLimit, + routing::{get, post}, +}; use crate::AppState; pub(crate) fn router() -> Router { Router::new() .route("/admin/import", get(logic::import_page)) + // The preparation, step by step. Each one swaps the step block and + // leaves the rest of the form alone. + .route("/admin/import/source", post(logic::source_step)) + .route("/admin/import/prepare", post(logic::prepare_step)) + .route("/admin/import/preview", post(logic::preview_step)) + // The two things a prepared import can be: rows in the table, or a file + // to look at first. Both read the same preparation. .route("/admin/import", post(logic::import_csv)) - .route("/admin/import/normalize", post(logic::normalize_headers)) + .route("/admin/import/prepared.csv", post(logic::download_prepared)) + .route("/admin/import/template.csv", post(logic::download_template)) .layer(DefaultBodyLimit::max(128 * 1024 * 1024)) } diff --git a/web/src/pages/import_export/import/prepare.rs b/web/src/pages/import_export/import/prepare.rs new file mode 100644 index 00000000..39d4a3b4 --- /dev/null +++ b/web/src/pages/import_export/import/prepare.rs @@ -0,0 +1,565 @@ +//! Turning what the user uploaded into the one CSV the import understands. +//! +//! Everything here is about *position*. A value lands in a column because the +//! user pointed position 3 at `active`, and for no other reason — not because +//! a source header spells something similar, not because the words look alike. +//! The source's own header, when it has one, is read out for orientation and +//! then thrown away. +//! +//! The output is the canonical form: fully quoted CSV whose header names real +//! destination columns, in the order the source presents them. From there the +//! import is the strict one it always was — types, validations, scripts, links +//! and permissions are all the server's, unchanged. + +use std::collections::HashSet; + +use crate::{i18n::Locale, tr}; + +use super::super::common::csv::{parse_csv, write_record}; + +/// How the uploaded text is laid out. +/// +/// Always the user's answer, never inferred. A data row can hold words that +/// read exactly like column names — `"name","num"` is a perfectly good pair of +/// customer records — so deciding for them is how a real row gets silently +/// eaten as a header. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum SourceMode { + /// The first row is the source's own header. It labels the mapping rows + /// and is not imported. + #[default] + Header, + /// Every row is data. + Data, + /// No source at all: pick and order destination columns, and take away a + /// header to fill in elsewhere. + Template, +} + +impl SourceMode { + pub(crate) fn parse(locale: Locale, raw: &str) -> Result { + match raw { + "header" => Ok(Self::Header), + "data" => Ok(Self::Data), + "template" => Ok(Self::Template), + _ => Err(tr!(locale, "import-err-source-mode")), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Header => "header", + Self::Data => "data", + Self::Template => "template", + } + } + + /// Whether a source file is needed at all. A template is generated from the + /// table, so there is nothing to upload. + pub(crate) fn reads_a_file(self) -> bool { + !matches!(self, Self::Template) + } +} + +/// The source file, split into the header it states and the rows that carry +/// values. +#[derive(Debug)] +pub(crate) struct Source { + /// The source's own header row, kept only to label the mapping rows. It is + /// `None` for a data-only file, and it never decides anything. + pub header: Option>, + pub rows: Vec>, + /// How many positions every row has. The mapping has exactly this many + /// entries, which is what makes "position 3" mean one thing. + pub width: usize, +} + +impl Source { + /// What the first data row holds at each position, for the mapping step to + /// show beside the destination picker. An example is the one thing that + /// reliably tells a user which column they are looking at. + pub(crate) fn examples(&self) -> Vec { + (0..self.width) + .map(|position| { + self.rows + .first() + .and_then(|row| row.get(position)) + .cloned() + .unwrap_or_default() + }) + .collect() + } + + /// The source's name for a position, when it has one. + pub(crate) fn source_name(&self, position: usize) -> Option { + self.header + .as_ref() + .and_then(|header| header.get(position)) + .cloned() + } +} + +/// Reads the uploaded text as `mode` says it is laid out. +pub(crate) fn read_source(locale: Locale, csv: &str, mode: SourceMode) -> Result { + let mut rows = parse_csv(locale, csv)?; + let header = match mode { + SourceMode::Header => { + if rows.len() < 2 { + return Err(tr!(locale, "import-err-no-data-rows")); + } + Some(rows.remove(0)) + } + SourceMode::Data => None, + // Nothing to read: the caller decides the columns from the table. + SourceMode::Template => return Err(tr!(locale, "import-err-template-has-no-source")), + }; + if rows.is_empty() { + return Err(tr!(locale, "import-err-no-data-rows")); + } + + let width = header + .as_ref() + .map_or_else(|| rows[0].len(), |header| header.len()); + if width == 0 { + return Err(tr!(locale, "import-err-empty")); + } + // Positions only mean anything if every row has the same ones. A short row + // would otherwise shift every value after the gap into the wrong column. + if let Some(row) = rows.iter().find(|row| row.len() != width) { + return Err(tr!( + locale, + "import-err-row-width", + "expected" => width as i64, + "found" => row.len() as i64, + )); + } + + Ok(Source { header, rows, width }) +} + +/// Where each source position is to be written, `None` being "ignore". +#[derive(Debug)] +pub(crate) struct Mapping(Vec>); + +impl Mapping { + /// The destination chosen for a position, for the form to re-render with + /// the user's own answers still selected. + pub(crate) fn target(&self, position: usize) -> Option<&str> { + self.0.get(position).and_then(Option::as_deref) + } + + pub(crate) fn len(&self) -> usize { + self.0.len() + } +} + +/// Reads the posted mapping and checks the rules that make "proper data in the +/// proper place" true rather than hoped for. +/// +/// `writable` is the destination list the table itself declares, loaded again +/// on every step: a column dropped or renamed while the form was open is caught +/// here instead of writing values into a column that no longer means what the +/// user chose. +pub(crate) fn read_mapping( + locale: Locale, + posted: &[String], + width: usize, + writable: &[String], +) -> Result { + // One select per position, always posted, so the form's shape and the + // file's have to agree. They will not if the CSV was edited after the + // mapping was built. + if posted.len() != width { + return Err(tr!( + locale, + "import-err-mapping-stale", + "positions" => width as i64, + "mapped" => posted.len() as i64, + )); + } + + let known = writable.iter().map(String::as_str).collect::>(); + let mut used = HashSet::new(); + let mut targets = Vec::with_capacity(width); + for (position, target) in posted.iter().enumerate() { + let target = target.trim(); + if target.is_empty() { + targets.push(None); + continue; + } + if !known.contains(target) { + return Err(tr!( + locale, + "import-err-mapping-unknown-column", + "column" => target.to_string(), + "position" => (position + 1) as i64, + )); + } + // Two positions pointed at one column let whichever is read second + // decide the value, silently. Refused instead. + if !used.insert(target.to_string()) { + return Err(tr!( + locale, + "import-err-mapping-duplicate", + "column" => target.to_string(), + )); + } + targets.push(Some(target.to_string())); + } + if used.is_empty() { + return Err(tr!(locale, "import-err-mapping-empty")); + } + Ok(Mapping(targets)) +} + +/// The canonical import, and what it leaves behind. +pub(crate) struct Prepared { + /// The destination header, in source-position order — which is the only + /// order the data can be written in, since the values arrive in it. + pub columns: Vec, + /// Data rows holding only the mapped positions, aligned to `columns`. + pub rows: Vec>, + /// The source positions sent nowhere, numbered from 1 as the mapping step + /// numbers them. + pub ignored: Vec, + /// Destination columns this import does not write. The server fills them + /// with their defaults, or refuses the row if it cannot. + pub omitted: Vec, +} + +impl Prepared { + pub(crate) fn row_count(&self) -> usize { + self.rows.len() + } + + /// The first `limit` rows, for the preview. A preview is for recognising + /// your own data, and nobody recognises it on row 40 000. + pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec] { + &self.rows[..self.rows.len().min(limit)] + } + + pub(crate) fn hidden_rows(&self, limit: usize) -> usize { + self.rows.len().saturating_sub(limit) + } +} + +/// Applies the mapping: the values that were pointed somewhere, under the names +/// they were pointed at. +pub(crate) fn prepare(mapping: &Mapping, source: &Source, writable: &[String]) -> Prepared { + let taken = (0..mapping.len()) + .filter_map(|position| Some((position, mapping.target(position)?.to_string()))) + .collect::>(); + + let columns = taken + .iter() + .map(|(_, column)| column.clone()) + .collect::>(); + let rows = source + .rows + .iter() + .map(|row| { + taken + .iter() + .map(|(position, _)| row.get(*position).cloned().unwrap_or_default()) + .collect() + }) + .collect(); + let ignored = (0..source.width) + .filter(|position| mapping.target(*position).is_none()) + .map(|position| position + 1) + .collect(); + let omitted = writable + .iter() + .filter(|column| !columns.contains(column)) + .cloned() + .collect(); + + Prepared { + columns, + rows, + ignored, + omitted, + } +} + +/// The prepared import as text: exactly what the importer reads, and exactly +/// what "Download prepared CSV" hands over, so inspecting the file and +/// importing it cannot disagree. +pub(crate) fn canonical_csv(prepared: &Prepared) -> String { + let mut csv = String::new(); + write_record(&mut csv, &prepared.columns); + for row in &prepared.rows { + write_record(&mut csv, row); + } + csv +} + +/// A header and nothing else: the columns the user picked, in the order they +/// arranged them, ready to be filled in elsewhere and brought back as a file +/// with a header. +pub(crate) fn template_csv(columns: &[String]) -> String { + let mut csv = String::new(); + write_record(&mut csv, columns); + csv +} + +/// The destination a position starts out pointed at when the mapping step first +/// opens. +/// +/// Only an exact match counts, and only against a real destination column. That +/// is not name interpretation: a header cell that *is* the column's name is the +/// column's name, which is the case whenever the file came from this system's +/// own template or export. Anything else starts at "Ignore", and every row is +/// in front of the user to confirm or change before a single value moves. +pub(crate) fn suggest_mapping(source: &Source, writable: &[String]) -> Vec { + let mut used = HashSet::new(); + (0..source.width) + .map(|position| { + let Some(name) = source.source_name(position) else { + return String::new(); + }; + if writable.contains(&name) && used.insert(name.clone()) { + name + } else { + String::new() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + fn writable() -> Vec { + strings(&["name", "company_number", "active", "note"]) + } + + /// A data-only file is all rows, and nothing in it is read as a header — + /// even when the first row happens to hold the column names. + #[test] + fn a_data_only_file_keeps_its_first_row() { + let csv = "\"name\",\"num\"\n\"Acme\",\"12345678\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap(); + assert!(source.header.is_none()); + assert_eq!(source.rows.len(), 2); + assert_eq!(source.examples(), strings(&["name", "num"])); + } + + /// A file declared to have a header loses its first row, and that row is + /// only ever shown back to the user. + #[test] + fn a_header_file_loses_its_first_row_to_labels() { + let csv = "\"Company\",\"Registration number\"\n\"Acme\",\"12345678\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap(); + assert_eq!(source.rows.len(), 1); + assert_eq!(source.source_name(0).as_deref(), Some("Company")); + assert_eq!(source.examples(), strings(&["Acme", "12345678"])); + } + + /// A header with no rows under it is not an import, and says so rather than + /// preparing an empty one. + #[test] + fn a_header_with_nothing_under_it_is_refused() { + let csv = "\"Company\",\"Registration number\"\n"; + assert!(read_source(Locale::default(), csv, SourceMode::Header).is_err()); + } + + /// Positions only mean something if every row has the same ones. + #[test] + fn rows_of_different_widths_are_refused() { + let csv = "\"Acme\",\"12345678\"\n\"Example\"\n"; + let error = read_source(Locale::default(), csv, SourceMode::Data) + .expect_err("a short row shifts every value after it"); + assert!(error.contains('2') && error.contains('1'), "{error}"); + } + + /// The heart of it: the user's choices decide where values go, source names + /// are irrelevant, and an ignored position takes its value nowhere. + #[test] + fn values_land_where_the_user_pointed_them() { + let csv = "\"whatever\",\"old field 7\",\"enabled value\",\"legacy\"\n\ + \"Acme\",\"12345678\",\"true\",\"drop me\"\n\ + \"Example\",\"87654321\",\"false\",\"drop me too\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap(); + let mapping = read_mapping( + Locale::default(), + &strings(&["name", "company_number", "active", ""]), + source.width, + &writable(), + ) + .unwrap(); + let prepared = prepare(&mapping, &source, &writable()); + + assert_eq!( + prepared.columns, + strings(&["name", "company_number", "active"]) + ); + assert_eq!(prepared.rows[0], strings(&["Acme", "12345678", "true"])); + assert_eq!(prepared.ignored, vec![4]); + assert_eq!(prepared.omitted, strings(&["note"])); + assert_eq!( + canonical_csv(&prepared), + "\"name\",\"company_number\",\"active\"\n\ + \"Acme\",\"12345678\",\"true\"\n\ + \"Example\",\"87654321\",\"false\"\n" + ); + } + + /// Out-of-order and sparse mappings are the normal case, not an edge one: + /// the header follows the source's positions, because the values do. + #[test] + fn the_prepared_header_follows_the_source_order() { + let csv = "\"Acme\",\"unused legacy value\",\"true\",\"12345678\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap(); + let mapping = read_mapping( + Locale::default(), + &strings(&["name", "", "active", "company_number"]), + source.width, + &writable(), + ) + .unwrap(); + let prepared = prepare(&mapping, &source, &writable()); + + assert_eq!( + canonical_csv(&prepared), + "\"name\",\"active\",\"company_number\"\n\"Acme\",\"true\",\"12345678\"\n" + ); + assert_eq!(prepared.ignored, vec![2]); + } + + /// What is downloaded and what is imported are the same file. The import + /// reads `Prepared` directly rather than its own text, so this is the check + /// that the two cannot drift apart. + #[test] + fn the_downloaded_csv_parses_back_to_the_rows_that_get_imported() { + let csv = "\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap(); + let mapping = read_mapping( + Locale::default(), + &strings(&["name", "note"]), + source.width, + &writable(), + ) + .unwrap(); + let prepared = prepare(&mapping, &source, &writable()); + + let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap(); + assert_eq!(reparsed[0], prepared.columns); + assert_eq!(reparsed[1..], prepared.rows[..]); + } + + /// One destination cannot be filled from two positions: the second would + /// silently win. + #[test] + fn a_destination_cannot_be_chosen_twice() { + let error = read_mapping( + Locale::default(), + &strings(&["name", "name"]), + 2, + &writable(), + ) + .expect_err("two positions cannot both be `name`"); + assert!(error.contains("name"), "{error}"); + } + + /// A destination that is not a writable column of this table is refused, + /// whatever the form posted — the table is asked again on every step. + #[test] + fn a_destination_the_table_does_not_offer_is_refused() { + let error = read_mapping( + Locale::default(), + &strings(&["name", "id"]), + 2, + &writable(), + ) + .expect_err("`id` is the server's"); + assert!(error.contains("id"), "{error}"); + } + + /// A mapping built against a different file is refused rather than applied + /// to whatever positions happen to line up. + #[test] + fn a_mapping_that_does_not_cover_the_file_is_refused() { + assert!(read_mapping(Locale::default(), &strings(&["name"]), 3, &writable()).is_err()); + } + + /// Ignoring everything is not an import. + #[test] + fn a_mapping_that_writes_nothing_is_refused() { + assert!(read_mapping(Locale::default(), &strings(&["", ""]), 2, &writable()).is_err()); + } + + /// A file this system generated the template for comes back with its own + /// column names, so every row starts out pointed at the right place — still + /// shown, still confirmed, never applied on its own. + #[test] + fn an_exact_header_starts_the_mapping_off() { + let csv = "\"name\",\"active\"\n\"Acme\",\"true\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap(); + assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", "active"])); + } + + /// Anything that is not exactly a column name starts at "Ignore". There is + /// no folding, no trimming and no similarity: ` name` and `Name` are not + /// the column `name`, and "Company" is not a guess anyone should make. + #[test] + fn nothing_but_an_exact_name_is_suggested() { + let csv = "\"Company\",\" name\",\"NAME\",\"note\"\n\"Acme\",\"x\",\"y\",\"z\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap(); + assert_eq!( + suggest_mapping(&source, &writable()), + strings(&["", "", "", "note"]) + ); + } + + /// A header naming one column twice suggests it once: the second would be + /// a duplicate the mapping step refuses anyway. + #[test] + fn a_repeated_header_is_only_suggested_once() { + let csv = "\"name\",\"name\"\n\"Acme\",\"Other\"\n"; + let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap(); + assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", ""])); + } + + /// The generated template is a header and nothing else, in the order it was + /// arranged in. + #[test] + fn a_template_is_the_chosen_columns_in_the_chosen_order() { + assert_eq!( + template_csv(&strings(&["active", "name", "company_number"])), + "\"active\",\"name\",\"company_number\"\n" + ); + } + + /// And it is a file this page can read straight back: the header mode plus + /// the exact-name suggestion close the loop. + #[test] + fn a_generated_template_comes_back_ready_to_import() { + let mut csv = template_csv(&strings(&["name", "active"])); + csv.push_str("\"Acme\",\"true\"\n"); + let source = read_source(Locale::default(), &csv, SourceMode::Header).unwrap(); + let mapping = read_mapping( + Locale::default(), + &suggest_mapping(&source, &writable()), + source.width, + &writable(), + ) + .unwrap(); + let prepared = prepare(&mapping, &source, &writable()); + assert_eq!(prepared.columns, strings(&["name", "active"])); + assert_eq!(prepared.rows, vec![strings(&["Acme", "true"])]); + } + + #[test] + fn a_source_mode_round_trips_and_an_unknown_one_is_refused() { + for mode in [SourceMode::Header, SourceMode::Data, SourceMode::Template] { + assert_eq!(SourceMode::parse(Locale::default(), mode.as_str()), Ok(mode)); + } + assert!(SourceMode::parse(Locale::default(), "guess").is_err()); + } +} diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index 3c59bc22..71926537 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -1,53 +1,214 @@ use crate::{i18n::Locale, tr}; +use super::prepare::SourceMode; + +/// Every field the import form carries, at every step. +/// +/// The whole preparation is one form posted back and forth: the page keeps no +/// server-side session, so each step re-renders the fields the next one needs +/// and the user can go back without losing what they chose. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct ImportForm { #[serde(default)] pub profile_name: String, - /// One entry per checked table. The form posts the key once per checked - /// box, which only `axum_extra`'s `Form` decodes into a `Vec`. + /// One table. An import writes into one table, and a file for two tables is + /// two prepared imports rather than one file with a second header row. #[serde(default)] - pub table_names: Vec, + pub table_name: String, + /// `header`, `data` or `template` — see [`SourceMode`]. Kept as text so an + /// unrecognised value is a message rather than a form rejection. + #[serde(default)] + pub source_mode: String, #[serde(default)] pub csv_data: String, - /// Whether a system column in the file is written rather than left to the - /// server. Only `deleted` can be: see - /// [`IMPORTABLE_SYSTEM_COLUMNS`](super::super::common::schema::IMPORTABLE_SYSTEM_COLUMNS). - /// An unchecked box is not posted at all, so its absence is the `false`. + /// The destination chosen for each source position, in position order, one + /// entry per position; empty means "ignore". Posted once per ` + + + diff --git a/web/templates/pages/import_export/import/fields.html b/web/templates/pages/import_export/import/fields.html deleted file mode 100644 index b08bb094..00000000 --- a/web/templates/pages/import_export/import/fields.html +++ /dev/null @@ -1,40 +0,0 @@ -{# - The form's fields, on their own so that "Normalize headers" can hand them - back with the rewritten CSV in the textarea — see - crate::pages::import_export::import::ui::ImportFields. `changed` is `None` - when the page first renders and `Some` once a rewrite has run. -#} -
-
- - - - - {{ nav.tr("import-csv-format-hint") }} -
- - {{ nav.tr("import-system-columns-hint") }} - {%- if let Some(changed) = changed %} -

{% if changed %}{{ nav.tr("import-normalized-message") }}{% else %}{{ nav.tr("import-normalized-unchanged") }}{% endif %}

- {%- endif %} -
diff --git a/web/templates/pages/import_export/import/import.html b/web/templates/pages/import_export/import/import.html index 780eed3d..078971fc 100644 --- a/web/templates/pages/import_export/import/import.html +++ b/web/templates/pages/import_export/import/import.html @@ -1,6 +1,5 @@ {# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #} {% extends "ui/form_page.html" %} -{% import "ui/alert.html" as alert %} {% block title %}{{ nav.tr("import-title") }}{% endblock %} {% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %} @@ -8,22 +7,33 @@ {% block lead %}

{{ nav.tr("import-lead") }}

{% endblock %} {% block form %} -
- {% include "pages/import_export/import/fields.html" %} -
- {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%} -
-
- {{ nav.tr("common-cancel") }} - {# Rewrites the header rows and puts the result back in the textarea. It - imports nothing: the file that gets imported is the one the user can see - and has had the chance to read. #} - - -
+{# + One form for the whole preparation. The steps swap the block inside it rather + than the form itself, so the scope, the table and the CSV the user chose stay + where they are while they move back and forth. + + Its own `action` is the prepared-CSV download: a file has to come back from a + real browser submit, so the plain submit button is the one that downloads and + every other button carries an `hx-post` of its own. +#} + + {% include "pages/import_export/import/step.html" %}
{% include "pages/import_export/table_picker.html" %} +{% include "pages/import_export/import/behaviour.html" %} +{# + A refusal is not a step. Without this the alert answering "Continue" would be + swapped in place of the step it was refusing, and the user would lose the + mapping they were being told to fix. +#} + {% endblock %} diff --git a/web/templates/pages/import_export/import/step.html b/web/templates/pages/import_export/import/step.html new file mode 100644 index 00000000..8ec7f4d4 --- /dev/null +++ b/web/templates/pages/import_export/import/step.html @@ -0,0 +1,226 @@ +{# + The preparation, one step at a time — + crate::pages::import_export::import::ui::ImportStep. + + Every button that moves between steps swaps this whole block, so each step + re-states the answers the next one needs as hidden fields. The page keeps no + server-side draft: what the form carries is the entire state of the + preparation. +#} +
+{% match page.step %} + +{#- ------------------------------------------------------------------ + Step 1 — where the data is going, and how the source is laid out. + ------------------------------------------------------------------ -#} +{% when Step::Source %} +
+ + + + {# + Always asked, never guessed. A data row can hold words that read exactly + like column names, so deciding for the user is how a real row gets eaten + as a header. + #} +
+ {{ nav.tr("import-source-mode") }} + + + +

{{ nav.tr("import-source-mode-hint") }}

+
+ +
+
+ + + {{ nav.tr("import-csv-format-hint") }} +
+
+
+
+ {{ nav.tr("common-cancel") }} + +
+ +{#- ------------------------------------------------------------------ + Step 2a — one row per source position, pointed wherever the user says. + ------------------------------------------------------------------ -#} +{% when Step::Mapping with (step) %} + {% include "pages/import_export/import/carried.html" %} +
+

{{ nav.tr_args("import-mapping-heading", [("table", step.table_name.clone())]) }}

+

{{ nav.tr("import-mapping-hint") }}

+

{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}

+ + + + + {% if step.has_source_names %}{% endif %} + + + + + + {% for row in step.rows %} + + + {% if step.has_source_names %}{% endif %} + + + + {% endfor %} + +
{{ nav.tr("import-th-position") }}{{ nav.tr("import-th-source-name") }}{{ nav.tr("import-th-example") }}{{ nav.tr("import-th-destination") }}
{{ row.position }}{% if let Some(name) = row.source_name %}{{ name }}{% endif %}{{ row.example }} + +
+
+
+ + +
+ +{#- ------------------------------------------------------------------ + Step 2b — the template generator: pick the columns, arrange them, take + away the header. + ------------------------------------------------------------------ -#} +{% when Step::Template with (step) %} + {% include "pages/import_export/import/carried.html" %} +
+

{{ nav.tr_args("import-template-heading", [("table", step.table_name.clone())]) }}

+

{{ nav.tr("import-template-hint") }}

+ + + + + + + + + + {% for row in step.rows %} + + + + + + {% endfor %} + +
{{ nav.tr("import-th-order") }}{{ nav.tr("import-th-include") }}{{ nav.tr("import-th-destination") }}
+ + + + {# + Both fields post in document order, so moving a row moves the + generated header with it: `template_order` remembers where the + unticked ones sit, `template_columns` is the header itself. + #} + + + {{ row.name }}{% if row.required %} {{ nav.tr("import-required-column") }}{% endif %}
+ {% if step.chosen > 0 %} +

{{ nav.tr("import-template-generated") }}

+
{{ step.header }}
+ {% else %} +

{{ nav.tr("import-template-nothing-chosen") }}

+ {% endif %} +

{{ nav.tr("import-template-round-trip") }}

+
+
+ + {# A real submit: the answer is a file. #} + +
+ +{#- ------------------------------------------------------------------ + Step 3 — the prepared import, exactly as it will be sent. + ------------------------------------------------------------------ -#} +{% when Step::Preview with (step) %} + {% include "pages/import_export/import/carried.html" %} + {% for value in page.form.mapping %}{% endfor %} +
+

{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}

+
+
+
{{ nav.tr("import-summary-rows") }}
+
{{ step.source_rows }}
+
+
+
{{ nav.tr("import-summary-columns-used") }}
+
{{ step.columns|join(", ") }}
+
+
+
{{ nav.tr("import-summary-ignored") }}
+
{% if step.ignored.is_empty() %}{% else %}{{ step.ignored|join(", ") }}{% endif %}
+
+
+
{{ nav.tr("import-summary-omitted") }}
+
{% if step.omitted.is_empty() %}{% else %}{{ step.omitted|join(", ") }}{% endif %}
+
+
+ {% if !step.missing_required.is_empty() %} +

{{ nav.tr_args("import-summary-missing-required", [("columns", step.missing_required.join(", "))]) }}

+ {% endif %} + +
+ + {% for column in step.columns %}{% endfor %} + + {% for row in step.rows %} + {% for value in row %}{% endfor %} + {% endfor %} + +
{{ column }}
{{ value }}
+
+ {% if step.hidden_rows > 0 %} +

{{ nav.tr_args("import-preview-more-rows", [("rows", step.hidden_rows.to_string())]) }}

+ {% endif %} + +
+ {{ nav.tr("import-prepared-csv") }} +

{{ nav.tr("import-prepared-csv-hint") }}

+
{{ step.csv }}
+
+
+
+ + {# A real submit: the answer is a file. #} + + +
+{% endmatch %} + +
+