import improvements2

This commit is contained in:
Priec
2026-08-17 15:20:31 +02:00
parent 1b9136f1de
commit 71807f9a6d
15 changed files with 944 additions and 1292 deletions

View File

@@ -566,23 +566,18 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export ------------------------------------------------------------- # --- Import / export -------------------------------------------------------------
transfer-eyebrow = Přenos dat transfer-eyebrow = Přenos dat
import-title = Import CSV import-title = Import CSV
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-lead = Import připravíte ve třech krocích: vyberete tabulku a předáte soubor, řeknete, odkud bere hodnotu každý sloupec tabulky, a výsledek si prohlédnete dřív, než se cokoli zapíše.
import-scope = Rozsah import-scope = Rozsah
import-choose-scope = Vyberte globální nebo profil import-choose-scope = Vyberte globální nebo profil
import-target-tables = Cílová tabulka import-target-tables = Cílová tabulka
import-choose-table = Vyberte tabulku import-choose-table = Vyberte tabulku
import-csv-file = Soubor CSV import-csv-file = Soubor CSV
import-csv-data = Data 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: "Acme","12345678","true" import-csv-format-hint = První řádek je vlastní záhlaví souboru a každý další řádek jsou data. Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "hl","he","s"
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-continue = Pokračovat
import-carried = Import do tabulky { $table } v rozsahu { $scope }. import-carried = Import do tabulky { $table } v rozsahu { $scope }.
import-error-title = CSV se nepodařilo importovat import-error-title = CSV se nepodařilo importovat
import-import-rows = Importovat připravená data import-import-rows = Importovat
import-success-title = Import dokončen import-success-title = Import dokončen
import-success-message = Vloženo { $inserted -> import-success-message = Vloženo { $inserted ->
[one] { $inserted } řádek [one] { $inserted } řádek
@@ -594,43 +589,32 @@ import-success-message = Vloženo { $inserted ->
*[other] { $source_rows } připravených řádků *[other] { $source_rows } připravených řádků
}. }.
# --- Krok 2a: mapování ----------------------------------------------------- # --- Krok 2: mapování ------------------------------------------------------
import-mapping-heading = Pošlete každou pozici do sloupce tabulky { $table } import-mapping-heading = Odkud bere hodnotu každý sloupec 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-hint = Každý sloupec tabulky je uveden jednou, takže žádný z nich nelze naplnit dvakrát. Hodnoty se berou podle pozice: název zdrojového sloupce se zobrazuje proto, abyste jej rozeznali, a o ničem nerozhoduje. Sloupec ponechaný na Neimportovat se nezapíše a ostatní sloupce souboru se ignorují.
import-mapping-rows = Soubor obsahuje { $rows } datových řádků. import-mapping-rows = Soubor obsahuje { $rows } datových řádků.
import-th-position = Pozice import-th-destination = Sloupec tabulky
import-th-source-name = Název ve zdroji import-th-required = Povinný
import-th-example = Ukázková hodnota import-th-source = Bere hodnotu z
import-th-destination = Poslat do import-th-example = Ukázka
import-destination-ignore = Ignorovat import-source-none = Neimportovat
import-back-to-source = Zpět na zdroj import-source-option = { $name } — pozice { $position }
import-source-unnamed = pozice { $position }
import-back-to-source = Zpět na soubor
import-to-preview = Připravit a zobrazit 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 -------------------------------------------------------- # --- Krok 3: náhled --------------------------------------------------------
import-preview-heading = Připravený import pro tabulku { $table } import-preview-heading = Připravený import pro tabulku { $table }
import-summary-rows = Řádky zdroje import-summary-rows = Řádky zdroje
import-summary-columns-used = Použité sloupce import-summary-columns-used = Namapované sloupce tabulky
import-summary-ignored = Ignorované pozice zdroje import-summary-ignored = Ignorované zdrojové sloupce
import-summary-omitted = Vynechané cílové sloupce import-summary-omitted = Vynechané sloupce tabulky
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-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-preview-more-rows = Dalších { $rows } řádků je připraveno a zde se nezobrazuje.
import-prepared-csv = Připravené CSV 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-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-download-prepared = Stáhnout připravené CSV
import-back-to-mapping = Zpět na mapování import-back-to-mapping = Zpět na mapování
import-move-up = Posunout { $name } nahoru
import-move-down = Posunout { $name } dolů
# --- Odmítnutí ------------------------------------------------------------- # --- Odmítnutí -------------------------------------------------------------
import-err-select-profile = Vyberte rozsah. import-err-select-profile = Vyberte rozsah.
@@ -641,15 +625,12 @@ import-err-tables-not-in-profile = Cílová tabulka nepatří do vybraného rozs
import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky.
import-err-no-importable-columns = Tabulka '{ $table }' nemá žádné sloupce, do kterých by import mohl zapisovat. 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-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-row-width = Každý řádek musí obsahovat tolik hodnot jako záhlaví. Záhlaví má { $expected } a pozdější řádek { $found }.
import-err-no-data-rows = Soubor neobsahuje žádné datové řádky. import-err-no-data-rows = Soubor potřebuje řádek záhlaví a pod ním alespoň jeden datový řádek.
import-err-source-mode = Uveďte, jak je zdroj uspořádán. import-err-mapping-empty = Žádný sloupec tabulky není namapován, takže není co importovat.
import-err-template-has-no-source = Šablona se generuje z tabulky, takže není co připravovat ze souboru. import-err-source-used-twice = Zdrojový sloupec '{ $name }' na pozici { $position } se používá pro dva sloupce tabulky. Zdrojový sloupec může naplnit jen jeden sloupec tabulky.
import-err-template-empty = Zaškrtněte pro šablonu alespoň jeden sloupec. import-err-source-position-missing = Sloupec '{ $column }' má brát pozici { $position }, a soubor má jen { $positions }. Připravte mapování znovu.
import-err-mapping-stale = Mapování bylo sestaveno pro soubor s { $mapped } pozicemi a tento má { $positions }. Připravte jej znovu. import-err-destination-missing = Namapovaný sloupec už v tabulce '{ $table }' neexistuje nebo do něj už import nemůže zapisovat. Připravte mapování 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-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í ke 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-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu.

View File

@@ -556,23 +556,18 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export ------------------------------------------------------- # --- Import / export -------------------------------------------------------
transfer-eyebrow = Data transfer transfer-eyebrow = Data transfer
import-title = Import CSV import-title = Import CSV
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-lead = Prepare an import in three steps: choose the table and hand over the file, say where each column of the table takes its value from, then look at the result before anything is written.
import-scope = Scope import-scope = Scope
import-choose-scope = Choose global or a profile import-choose-scope = Choose global or a profile
import-target-tables = Target table import-target-tables = Target table
import-choose-table = Choose a table import-choose-table = Choose a table
import-csv-file = CSV file import-csv-file = CSV file
import-csv-data = CSV data 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: "Acme","12345678","true" import-csv-format-hint = The first row is the file's own header, and every row after it is data. Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "hl","he","s"
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-continue = Continue
import-carried = Importing into { $table } in { $scope }. import-carried = Importing into { $table } in { $scope }.
import-error-title = Could not import CSV import-error-title = Could not import CSV
import-import-rows = Import prepared data import-import-rows = Import
import-success-title = Import complete import-success-title = Import complete
import-success-message = Inserted { $inserted -> import-success-message = Inserted { $inserted ->
[one] { $inserted } row [one] { $inserted } row
@@ -582,43 +577,32 @@ import-success-message = Inserted { $inserted ->
*[other] { $source_rows } prepared rows *[other] { $source_rows } prepared rows
}. }.
# --- Step 2a: mapping ------------------------------------------------------ # --- Step 2: mapping -------------------------------------------------------
import-mapping-heading = Send each position to a column of { $table } import-mapping-heading = Where does each column of { $table } take its value from?
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-hint = Every column of the table is listed once, so none of them can be filled twice. Values are taken by position: a source column's name is shown so you can recognise it, and decides nothing. A column left on Do not import is not written, and the file's other columns are ignored.
import-mapping-rows = The file holds { $rows } data rows. import-mapping-rows = The file holds { $rows } data rows.
import-th-position = Position import-th-destination = Table column
import-th-source-name = Source name import-th-required = Required
import-th-example = Example value import-th-source = Takes its value from
import-th-destination = Send to import-th-example = Example
import-destination-ignore = Ignore import-source-none = Do not import
import-back-to-source = Back to the source import-source-option = { $name } — position { $position }
import-source-unnamed = position { $position }
import-back-to-source = Back to the file
import-to-preview = Prepare and preview 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 ------------------------------------------------------- # --- Step 3: preview -------------------------------------------------------
import-preview-heading = Prepared import for { $table } import-preview-heading = Prepared import for { $table }
import-summary-rows = Source rows import-summary-rows = Source rows
import-summary-columns-used = Columns used import-summary-columns-used = Mapped table columns
import-summary-ignored = Source positions ignored import-summary-ignored = Ignored source columns
import-summary-omitted = Destination columns omitted import-summary-omitted = Omitted table columns
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-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-preview-more-rows = { $rows } further rows are prepared and not shown here.
import-prepared-csv = The prepared CSV 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-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-download-prepared = Download prepared CSV
import-back-to-mapping = Back to the mapping import-back-to-mapping = Back to the mapping
import-move-up = Move { $name } up
import-move-down = Move { $name } down
# --- Refusals -------------------------------------------------------------- # --- Refusals --------------------------------------------------------------
import-err-select-profile = Select a scope. import-err-select-profile = Select a scope.
@@ -629,15 +613,12 @@ import-err-tables-not-in-profile = The target table does not belong to the selec
import-err-missing-structure = The backend omitted a requested table structure. import-err-missing-structure = The backend omitted a requested table structure.
import-err-no-importable-columns = Table '{ $table }' has no columns an import can write into. import-err-no-importable-columns = Table '{ $table }' has no columns an import can write into.
import-err-csv-row = Row { $row }: { $error } 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-row-width = Every row must hold as many values as the header. The header has { $expected } and a later row has { $found }.
import-err-no-data-rows = The file holds no data rows. import-err-no-data-rows = The file needs a header row and at least one data row under it.
import-err-source-mode = Say how the source is laid out. import-err-mapping-empty = No column of the table is mapped, so there is nothing to import.
import-err-template-has-no-source = A template is generated from the table, so there is no file to prepare. import-err-source-used-twice = Source column '{ $name }' at position { $position } is used for two table columns. A source column can fill one table column only.
import-err-template-empty = Tick at least one column for the template. import-err-source-position-missing = Column '{ $column }' is set to take position { $position }, and the file only has { $positions }. Prepare the mapping again.
import-err-mapping-stale = The mapping was built for a file of { $mapped } positions and this one has { $positions }. Prepare it again. import-err-destination-missing = A mapped column no longer exists in table '{ $table }', or is no longer one an import can write into. Prepare the mapping 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-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-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. import-err-unterminated-quote = CSV contains an unterminated quoted value.

View File

@@ -566,23 +566,18 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export --------------------------------------------------------- # --- Import / export ---------------------------------------------------------
transfer-eyebrow = Prenos údajov transfer-eyebrow = Prenos údajov
import-title = Import CSV import-title = Import CSV
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-lead = Import pripravíte v troch krokoch: vyberiete tabuľku a odovzdáte súbor, poviete, odkiaľ berie hodnotu každý stĺpec tabuľky, a výsledok si pozriete skôr, než sa čokoľvek zapíše.
import-scope = Rozsah import-scope = Rozsah
import-choose-scope = Vyberte globálne alebo profil import-choose-scope = Vyberte globálne alebo profil
import-target-tables = Cieľová tabuľka import-target-tables = Cieľová tabuľka
import-choose-table = Vyberte tabuľku import-choose-table = Vyberte tabuľku
import-csv-file = Súbor CSV import-csv-file = Súbor CSV
import-csv-data = Údaje 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: "Acme","12345678","true" import-csv-format-hint = Prvý riadok je vlastná hlavička súboru a každý ďalší riadok sú údaje. Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "hl","he","s"
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-continue = Pokračovať
import-carried = Import do tabuľky { $table } v rozsahu { $scope }. import-carried = Import do tabuľky { $table } v rozsahu { $scope }.
import-error-title = CSV sa nepodarilo importovať import-error-title = CSV sa nepodarilo importovať
import-import-rows = Importovať pripravené údaje import-import-rows = Importovať
import-success-title = Import dokončený import-success-title = Import dokončený
import-success-message = Vložený { $inserted -> import-success-message = Vložený { $inserted ->
[one] { $inserted } riadok [one] { $inserted } riadok
@@ -592,43 +587,32 @@ import-success-message = Vložený { $inserted ->
*[other] { $source_rows } pripravených riadkov *[other] { $source_rows } pripravených riadkov
}. }.
# --- Krok 2a: mapovanie ---------------------------------------------------- # --- Krok 2: mapovanie -----------------------------------------------------
import-mapping-heading = Pošlite každú pozíciu do stĺpca tabuľky { $table } import-mapping-heading = Odkiaľ berie hodnotu každý stĺpec 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-hint = Každý stĺpec tabuľky je uvedený raz, takže žiadny z nich sa nedá naplniť dvakrát. Hodnoty sa berú podľa pozície: názov zdrojového stĺpca sa zobrazuje preto, aby ste ho rozoznali, a o ničom nerozhoduje. Stĺpec ponechaný na Neimportovať sa nezapíše a ostatné stĺpce súboru sa ignorujú.
import-mapping-rows = Súbor obsahuje { $rows } riadkov údajov. import-mapping-rows = Súbor obsahuje { $rows } riadkov údajov.
import-th-position = Pozícia import-th-destination = Stĺpec tabuľky
import-th-source-name = Názov v zdroji import-th-required = Povinný
import-th-example = Ukážková hodnota import-th-source = Berie hodnotu z
import-th-destination = Poslať do import-th-example = Ukážka
import-destination-ignore = Ignorovať import-source-none = Neimportovať
import-back-to-source = Späť na zdroj import-source-option = { $name } — pozícia { $position }
import-source-unnamed = pozícia { $position }
import-back-to-source = Späť na súbor
import-to-preview = Pripraviť a zobraziť 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 -------------------------------------------------------- # --- Krok 3: náhľad --------------------------------------------------------
import-preview-heading = Pripravený import pre tabuľku { $table } import-preview-heading = Pripravený import pre tabuľku { $table }
import-summary-rows = Riadky zdroja import-summary-rows = Riadky zdroja
import-summary-columns-used = Použité stĺpce import-summary-columns-used = Namapované stĺpce tabuľky
import-summary-ignored = Ignorované pozície zdroja import-summary-ignored = Ignorované zdrojové stĺpce
import-summary-omitted = Vynechané cieľové stĺpce import-summary-omitted = Vynechané stĺpce tabuľky
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-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-preview-more-rows = Ďalších { $rows } riadkov je pripravených a tu sa nezobrazuje.
import-prepared-csv = Pripravené CSV 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-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-download-prepared = Stiahnuť pripravené CSV
import-back-to-mapping = Späť na mapovanie import-back-to-mapping = Späť na mapovanie
import-move-up = Posunúť { $name } nahor
import-move-down = Posunúť { $name } nadol
# --- Odmietnutia ----------------------------------------------------------- # --- Odmietnutia -----------------------------------------------------------
import-err-select-profile = Vyberte rozsah. import-err-select-profile = Vyberte rozsah.
@@ -639,15 +623,12 @@ import-err-tables-not-in-profile = Cieľová tabuľka nepatrí do vybraného roz
import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky.
import-err-no-importable-columns = Tabuľka '{ $table }' nemá žiadne stĺpce, do ktorých by import mohol zapisovať. 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-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-row-width = Každý riadok musí obsahovať toľko hodnôt ako hlavička. Hlavička má { $expected } a neskorší riadok { $found }.
import-err-no-data-rows = Súbor neobsahuje žiadne riadky údajov. import-err-no-data-rows = Súbor potrebuje riadok hlavičky a pod ním aspoň jeden riadok údajov.
import-err-source-mode = Uveďte, ako je zdroj usporiadaný. import-err-mapping-empty = Žiadny stĺpec tabuľky nie je namapovaný, takže nie je čo importovať.
import-err-template-has-no-source = Šablóna sa generuje z tabuľky, takže nie je čo pripravovať zo súboru. import-err-source-used-twice = Zdrojový stĺpec '{ $name }' na pozícii { $position } sa používa pre dva stĺpce tabuľky. Zdrojový stĺpec môže naplniť len jeden stĺpec tabuľky.
import-err-template-empty = Zaškrtnite pre šablónu aspoň jeden stĺpec. import-err-source-position-missing = Stĺpec '{ $column }' má brať pozíciu { $position }, a súbor má len { $positions }. Pripravte mapovanie znova.
import-err-mapping-stale = Mapovanie bolo zostavené pre súbor s { $mapped } pozíciami a tento má { $positions }. Pripravte ho znova. import-err-destination-missing = Namapovaný stĺpec už v tabuľke '{ $table }' neexistuje alebo do neho už import nemôže zapisovať. Pripravte mapovanie 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-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-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. import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu.

View File

@@ -473,7 +473,6 @@ mod tests {
("/admin/import/prepare", ""), ("/admin/import/prepare", ""),
("/admin/import/preview", ""), ("/admin/import/preview", ""),
("/admin/import/prepared.csv", ""), ("/admin/import/prepared.csv", ""),
("/admin/import/template.csv", ""),
("/admin/export.csv", ""), ("/admin/export.csv", ""),
] { ] {
let response = test_router() let response = test_router()

View File

@@ -80,52 +80,6 @@ fn is_read_omitted_column(name: &str) -> bool {
.any(|column| column.name == name) .any(|column| column.name == name)
} }
/// The columns an import may write into: the destinations the mapping step
/// offers, in the order the table declares them.
///
/// 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<String> {
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<String> {
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()
}
pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> { pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> {
schema schema
.columns .columns
@@ -234,64 +188,5 @@ 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()]);
}
} }

View File

@@ -0,0 +1,227 @@
//! The columns an import may write into, and how the form names one.
//!
//! The mapping is read destination-first: every writable column of the table
//! gets one row, and the row asks where its value comes from. That is what
//! makes "one destination filled twice" impossible to express rather than
//! merely refused — each destination appears exactly once, by construction.
use crate::definitions::table_structure::{TableColumn, TableStructureResponse};
use super::super::common::schema::{is_importable_system_column, is_system_column};
/// How a destination column is named in the form.
///
/// Not by its display name. A user column has a stable identity the server
/// assigns once — `column_id` — and it keeps that identity through a rename, so
/// a mapping built before a rename still means the same column afterwards
/// rather than silently pointing at whatever now answers to the old name.
///
/// The system columns have no such id (`column_id` is zero for them), so they
/// are named by the one thing they do have. That is safe for exactly the reason
/// the id exists: a system column's name is the server's and cannot be renamed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DestinationKey {
Column(i64),
System(String),
}
impl DestinationKey {
fn of(column: &TableColumn) -> Self {
if column.column_id == 0 {
Self::System(column.name.clone())
} else {
Self::Column(column.column_id)
}
}
/// What the hidden field carries.
pub(crate) fn encode(&self) -> String {
match self {
Self::Column(id) => format!("id:{id}"),
Self::System(name) => format!("system:{name}"),
}
}
pub(crate) fn parse(raw: &str) -> Option<Self> {
if let Some(id) = raw.strip_prefix("id:") {
return id.parse().ok().map(Self::Column);
}
raw.strip_prefix("system:")
.map(|name| Self::System(name.to_string()))
}
}
/// One column an import may write into.
#[derive(Clone, Debug)]
pub(crate) struct DestinationColumn {
pub key: DestinationKey,
/// The name the table currently shows, which is what the page displays and
/// what the prepared CSV's header says. The identity is `key`.
pub name: String,
/// Declared `NOT NULL`. A warning in the preview rather than a rule: the
/// column may have a default, and `information_schema` does not say which
/// do, so refusing here would block imports the server would accept.
pub required: bool,
}
/// The columns of `schema` an import may write into, in the order the table
/// declares them.
///
/// The exclusions are the server's own flags rather than a guess:
///
/// * `is_primary_key` — `id` comes from a sequence.
/// * `read_only` — set for a quantity-ledger column and for a link projection,
/// and an insert naming either is refused. 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 destination_columns(schema: &TableStructureResponse) -> Vec<DestinationColumn> {
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| DestinationColumn {
key: DestinationKey::of(column),
name: column.name.clone(),
// A system column's nullability is the server's business, and
// `deleted` is NOT NULL with a default on every managed table —
// reporting it would warn about every import ever prepared.
required: !column.is_nullable && !is_system_column(&column.name),
})
.collect()
}
/// The column a posted key names, in the table as it stands right now.
///
/// `None` means the table no longer has it — dropped, or made read-only, while
/// the form sat open. The caller turns that into a refusal; it must never
/// become "write it somewhere else".
pub(crate) fn resolve<'a>(
columns: &'a [DestinationColumn],
raw: &str,
) -> Option<&'a DestinationColumn> {
let key = DestinationKey::parse(raw)?;
columns.iter().find(|column| column.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
fn column(name: &str, column_id: i64) -> TableColumn {
TableColumn {
name: name.to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
column_id,
..Default::default()
}
}
fn schema() -> TableStructureResponse {
TableStructureResponse {
columns: vec![
TableColumn {
is_primary_key: true,
..column("id", 0)
},
column("deleted", 0),
column("row_revision", 0),
TableColumn {
is_nullable: false,
..column("number", 42)
},
// A quantity-ledger column: the server refuses an insert that
// names it, so it is not a destination.
TableColumn {
read_only: true,
..column("stock", 57)
},
// Accounting columns are generated companions but stay
// writable, so they are destinations like any other.
TableColumn {
generated: true,
generated_from: "accounting".to_string(),
..column("debit", 81)
},
column("created_at", 0),
],
}
}
#[test]
fn only_the_columns_an_insert_takes_are_offered() {
let columns = destination_columns(&schema());
assert_eq!(
columns
.iter()
.map(|column| column.name.as_str())
.collect::<Vec<_>>(),
vec!["deleted", "number", "debit"]
);
}
/// A user column is identified by the id the server gave it, so a rename
/// cannot move a mapping onto a different column. A system column has no
/// id and no rename, so its name is identity enough.
#[test]
fn a_user_column_is_named_by_its_stable_id_and_a_system_one_by_its_name() {
let columns = destination_columns(&schema());
assert_eq!(columns[0].key.encode(), "system:deleted");
assert_eq!(columns[1].key.encode(), "id:42");
for column in &columns {
assert_eq!(
DestinationKey::parse(&column.key.encode()).as_ref(),
Some(&column.key)
);
}
}
/// The point of the id: the mapping survives the rename, and follows the
/// column rather than the name.
#[test]
fn a_renamed_column_is_still_the_same_destination() {
let posted = destination_columns(&schema())[1].key.encode();
let mut renamed = schema();
renamed.columns[3].name = "invoice_number".to_string();
let columns = destination_columns(&renamed);
let resolved =
resolve(&columns, &posted).expect("column 42 is column 42 whatever it is called");
assert_eq!(resolved.name, "invoice_number");
}
/// A destination that is gone, or that the table has since made read-only,
/// resolves to nothing — which the caller has to refuse rather than guess
/// past.
#[test]
fn a_destination_the_table_no_longer_offers_resolves_to_nothing() {
let columns = destination_columns(&schema());
assert!(resolve(&columns, "id:57").is_none());
assert!(resolve(&columns, "id:999").is_none());
assert!(resolve(&columns, "system:row_revision").is_none());
assert!(resolve(&columns, "number").is_none());
assert!(resolve(&columns, "").is_none());
}
/// Only the user's own `NOT NULL` columns are reported as required.
#[test]
fn required_is_the_users_own_not_null_columns() {
let columns = destination_columns(&schema());
assert_eq!(
columns
.iter()
.filter(|column| column.required)
.map(|column| column.name.as_str())
.collect::<Vec<_>>(),
vec!["number"]
);
}
}

View File

@@ -5,8 +5,9 @@ use axum::{
http::{HeaderMap, HeaderValue, header}, http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
// A mapping posts `mapping` once per source position, and `axum::Form` // The mapping posts `destination` and `source_position` once per destination
// (serde_urlencoded) cannot decode repeated keys into a `Vec`. // row, and `axum::Form` (serde_urlencoded) cannot decode repeated keys into a
// `Vec`.
use axum_extra::extract::Form; use axum_extra::extract::Form;
use crate::{ use crate::{
@@ -22,16 +23,12 @@ use crate::{
use super::{ use super::{
super::common::{ super::common::{
loader::LoadError, loader::LoadError,
schema::{column_types, csv_value, importable_columns, required_columns}, schema::{column_types, csv_value},
}, },
destination::{DestinationColumn, destination_columns, resolve},
loader::load_page, loader::load_page,
prepare::{ prepare::{Prepared, Source, canonical_csv, prepare, read_mapping, read_source},
Prepared, SourceMode, canonical_csv, prepare, read_mapping, read_source, suggest_mapping, state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption, Step},
template_csv,
},
state::{
ImportForm, MappingRow, MappingStep, PreviewStep, Step, TemplateRow, TemplateStep,
},
ui, ui,
}; };
@@ -42,17 +39,25 @@ const PREVIEW_ROWS: usize = 20;
/// ///
/// Loaded again at every step rather than carried in the form. The destination /// 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 /// 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 /// time: a column dropped or made read-only while the form sat open turns into
/// refusal on the next click instead of a value written somewhere else. /// a refusal on the next click instead of a value written somewhere else.
struct Destination { struct Destination {
table_name: String, table_name: String,
/// The columns a mapping may point at. columns: Vec<DestinationColumn>,
columns: Vec<String>,
/// Of those, the ones declared `NOT NULL`.
required: Vec<String>,
types: HashMap<String, String>, types: HashMap<String, String>,
} }
impl Destination {
/// The destination names, for the parts of the preparation that only need
/// to know which columns exist.
fn names(&self) -> Vec<String> {
self.columns
.iter()
.map(|column| column.name.clone())
.collect()
}
}
/// GET /admin/import /// GET /admin/import
pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response { pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
match load_page(state, &headers, ImportForm::default(), Step::Source).await { match load_page(state, &headers, ImportForm::default(), Step::Source).await {
@@ -74,11 +79,8 @@ pub(crate) async fn source_step(
render_step(state, &headers, form, Step::Source).await render_step(state, &headers, form, Step::Source).await
} }
/// POST /admin/import/prepare — the second step: the mapping table for a file, /// POST /admin/import/prepare — the mapping: one row per writable destination
/// or the column chooser for a template. /// column, asking where its value comes from.
///
/// 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( pub(crate) async fn prepare_step(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -92,43 +94,48 @@ pub(crate) async fn prepare_step(
Ok(destination) => destination, Ok(destination) => destination,
Err(response) => return response, Err(response) => return response,
}; };
let mode = match form.mode(locale) { let source = match read_source(locale, &form.csv_data) {
Ok(mode) => mode, Ok(source) => source,
Err(message) => return reject(&headers, message), Err(message) => return reject(&headers, message),
}; };
let step = match mode { // What the user has already answered, if they are coming back from the
SourceMode::Template => template_step(&form, &destination), // preview. Keyed by the destination's identity rather than by row order, so
SourceMode::Header | SourceMode::Data => { // a table that gained or lost a column keeps the answers for the rest.
let source = match read_source(locale, &form.csv_data, mode) { let answered = form
Ok(source) => source, .rows()
Err(message) => return reject(&headers, message), .into_iter()
}; .filter_map(|(key, position)| Some((key.to_string(), position?)))
// A mapping that does not cover this file was built against a .collect::<HashMap<_, _>>();
// different one — the user edited the CSV and came back. Start it
// over rather than lining up positions that no longer correspond. let step = Step::Mapping(MappingStep {
let chosen = if form.mapping.len() == source.width { table_name: destination.table_name.clone(),
form.mapping.clone() rows: destination
} else { .columns
suggest_mapping(&source, &destination.columns) .iter()
}; .map(|column| {
let examples = source.examples(); let key = column.key.encode();
Step::Mapping(MappingStep { // Nothing starts out mapped. An exact name match would be a
table_name: destination.table_name.clone(), // reasonable guess, but a guess is what this page exists to
columns: destination.columns.clone(), // avoid — every destination is the user's to answer.
rows: (0..source.width) let chosen = answered
.map(|position| MappingRow { .get(&key)
position: position + 1, .copied()
source_name: source.source_name(position), .filter(|index| *index < source.width());
example: examples.get(position).cloned().unwrap_or_default(), MappingRow {
target: chosen.get(position).cloned().unwrap_or_default(), key,
}) name: column.name.clone(),
.collect(), required: column.required,
source_rows: source.rows.len(), example: chosen
has_source_names: source.header.is_some(), .map(|index| source.example(index).to_string())
.unwrap_or_default(),
chosen: chosen.map(|index| (index + 1).to_string()).unwrap_or_default(),
}
}) })
} .collect(),
}; sources: source_options(locale, &source),
source_rows: source.rows.len(),
});
render_step(state, &headers, form, step).await render_step(state, &headers, form, step).await
} }
@@ -142,12 +149,12 @@ pub(crate) async fn preview_step(
return rejection; return rejection;
} }
let locale = Locale::from_headers(&headers); let locale = Locale::from_headers(&headers);
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await { let (destination, source, prepared) = match prepared(state.clone(), &headers, &form).await {
Ok(parts) => parts, Ok(parts) => parts,
Err(response) => return response, Err(response) => return response,
}; };
let source_names = source_names(locale, &form); let sources = source_options(locale, &source);
let step = Step::Preview(PreviewStep { let step = Step::Preview(PreviewStep {
table_name: destination.table_name.clone(), table_name: destination.table_name.clone(),
columns: prepared.columns.clone(), columns: prepared.columns.clone(),
@@ -157,16 +164,13 @@ pub(crate) async fn preview_step(
ignored: prepared ignored: prepared
.ignored .ignored
.iter() .iter()
.map(|position| match source_names.get(position - 1) { .filter_map(|position| Some(sources.get(position - 1)?.label.clone()))
Some(name) if !name.is_empty() => format!("{position} ({name})"),
_ => position.to_string(),
})
.collect(), .collect(),
missing_required: destination missing_required: destination
.required .columns
.iter() .iter()
.filter(|column| !prepared.columns.contains(column)) .filter(|column| column.required && !prepared.columns.contains(&column.name))
.cloned() .map(|column| column.name.clone())
.collect(), .collect(),
omitted: prepared.omitted.clone(), omitted: prepared.omitted.clone(),
csv: canonical_csv(&prepared), csv: canonical_csv(&prepared),
@@ -192,7 +196,7 @@ pub(crate) async fn import_csv(
Ok(target) => target, Ok(target) => target,
Err(message) => return reject(&headers, message), Err(message) => return reject(&headers, message),
}; };
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await { let (destination, _, prepared) = match prepared(state.clone(), &headers, &form).await {
Ok(parts) => parts, Ok(parts) => parts,
Err(response) => return response, Err(response) => return response,
}; };
@@ -206,7 +210,9 @@ pub(crate) async fn import_csv(
tr!( tr!(
locale, locale,
"import-err-csv-row", "import-err-csv-row",
"row" => (index + 1) as i64, // The header is row 1 of the file the user is looking at,
// so the first data row is row 2.
"row" => (index + 2) as i64,
"error" => error, "error" => error,
) )
}) })
@@ -261,39 +267,24 @@ pub(crate) async fn download_prepared(
if let Some(rejection) = reject_cross_site(&headers) { if let Some(rejection) = reject_cross_site(&headers) {
return rejection; return rejection;
} }
let (destination, prepared) = match prepared(state, &headers, &form).await { let (destination, _, prepared) = match prepared(state, &headers, &form).await {
Ok(parts) => parts, Ok(parts) => parts,
Err(response) => return response, Err(response) => return response,
}; };
attachment( let csv = canonical_csv(&prepared);
canonical_csv(&prepared), let filename = format!("{}_prepared.csv", destination.table_name);
&format!("{}_prepared.csv", destination.table_name),
)
}
/// POST /admin/import/template.csv — the chosen columns as a header row, and let mut response = csv.into_response();
/// nothing else. response.headers_mut().insert(
pub(crate) async fn download_template( header::CONTENT_TYPE,
State(state): State<AppState>, HeaderValue::from_static("text/csv; charset=utf-8"),
headers: HeaderMap, );
Form(form): Form<ImportForm>, if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) {
) -> Response { response
if let Some(rejection) = reject_cross_site(&headers) { .headers_mut()
return rejection; .insert(header::CONTENT_DISPOSITION, value);
} }
let locale = Locale::from_headers(&headers); response
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 /// The table this import writes into, once the form's scope and table have been
@@ -340,7 +331,7 @@ async fn destination(
.remove(&table_name) .remove(&table_name)
.ok_or_else(|| unavailable(headers, tr!(locale, "import-err-missing-structure")))?; .ok_or_else(|| unavailable(headers, tr!(locale, "import-err-missing-structure")))?;
let columns = importable_columns(&structure); let columns = destination_columns(&structure);
if columns.is_empty() { if columns.is_empty() {
return Err(reject( return Err(reject(
headers, headers,
@@ -353,131 +344,72 @@ async fn destination(
} }
Ok(Destination { Ok(Destination {
table_name, table_name,
required: required_columns(&structure),
types: column_types(&structure), types: column_types(&structure),
columns, columns,
}) })
} }
/// The prepared import: the source read as the user said it is laid out, the /// The prepared import: the file read, the mapping resolved against the table
/// mapping checked against the table as it stands, and the two applied. /// as it stands, and the two applied.
/// ///
/// Shared by preview, download and import so that all three are the same file /// 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. /// looking at it and importing it cannot disagree about what it contains.
async fn prepared( async fn prepared(
state: AppState, state: AppState,
headers: &HeaderMap, headers: &HeaderMap,
form: &ImportForm, form: &ImportForm,
) -> Result<(Destination, Prepared), Response> { ) -> Result<(Destination, Source, Prepared), Response> {
let locale = Locale::from_headers(headers); let locale = Locale::from_headers(headers);
let destination = destination(state, headers, form).await?; let destination = destination(state, headers, form).await?;
let mode = form.mode(locale).map_err(|message| reject(headers, message))?; let source = read_source(locale, &form.csv_data).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 // Every posted destination has to still exist and still be writable. A key
/// button was pressed applied to it. // that resolves to nothing is refused rather than skipped: skipping it
fn template_step(form: &ImportForm, destination: &Destination) -> Step { // would quietly import a subset of what the preview promised.
let mut order = arranged_columns(form, destination); let mut chosen = Vec::new();
if let (Some(action), Some(index)) = (form.action.as_deref(), form.index) { for (key, position) in form.rows() {
let swap_with = match action { let column = resolve(&destination.columns, key).ok_or_else(|| {
"up" if index > 0 => Some(index - 1), reject(
"down" if index + 1 < order.len() => Some(index + 1), headers,
_ => None, tr!(
}; locale,
if let Some(other) = swap_with { "import-err-destination-missing",
order.swap(index, other); "table" => destination.table_name.clone(),
} ),
)
})?;
chosen.push((column.name.clone(), position));
} }
let chosen = order let assignments =
.iter() read_mapping(locale, &chosen, &source).map_err(|message| reject(headers, message))?;
.filter(|column| form.template_columns.contains(column)) let prepared = prepare(&assignments, &source, &destination.names());
.cloned() Ok((destination, source, prepared))
.collect::<Vec<_>>();
let last = order.len().saturating_sub(1);
Step::Template(TemplateStep {
table_name: destination.table_name.clone(),
rows: order
.iter()
.enumerate()
.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(),
header: if chosen.is_empty() {
String::new()
} else {
template_csv(&chosen)
},
chosen: chosen.len(),
})
} }
/// The destination columns in the order the template step has them arranged. /// The file's columns as every picker offers them: `hl — position 3`, and just
/// /// the position when the file left the name blank.
/// The table's own order until the user moves something, and thereafter what fn source_options(locale: Locale, source: &Source) -> Vec<SourceOption> {
/// the form carries — minus anything the table no longer has, plus anything it (0..source.width())
/// has gained, so a table changed mid-arrangement neither drops a column from .map(|index| {
/// the chooser nor offers one that is gone. let position = (index + 1) as i64;
fn arranged_columns(form: &ImportForm, destination: &Destination) -> Vec<String> { let name = source.name(index).trim();
let mut order = form SourceOption {
.template_order position: index + 1,
.iter() label: if name.is_empty() {
.filter(|column| destination.columns.contains(column)) tr!(locale, "import-source-unnamed", "position" => position)
.cloned() } else {
.collect::<Vec<_>>(); tr!(
order.dedup(); locale,
for column in &destination.columns { "import-source-option",
if !order.contains(column) { "name" => name.to_string(),
order.push(column.clone()); "position" => position,
} )
} },
order example: source.example(index).to_string(),
} }
})
/// The template's columns, in the arranged order, checked for being answered. .collect()
fn template_selection(
locale: Locale,
form: &ImportForm,
destination: &Destination,
) -> Result<Vec<String>, String> {
let chosen = arranged_columns(form, destination)
.into_iter()
.filter(|column| form.template_columns.contains(column))
.collect::<Vec<_>>();
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<String> {
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 /// One prepared row as the bulk insert takes it: values under their destination
@@ -519,22 +451,8 @@ async fn render_step(
} }
} }
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 /// 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. /// unknown scope, a position the file does not have, a cell no type accepts.
/// The buttons target `#submission-status`, so the answer is the alert fragment /// The buttons target `#submission-status`, so the answer is the alert fragment
/// rather than a whole page. /// rather than a whole page.
fn reject(headers: &HeaderMap, message: String) -> Response { fn reject(headers: &HeaderMap, message: String) -> Response {
@@ -569,134 +487,34 @@ fn unavailable(headers: &HeaderMap, message: String) -> Response {
mod tests { mod tests {
use super::*; use super::*;
fn strings(values: &[&str]) -> Vec<String> { fn source() -> Source {
values.iter().map(|value| value.to_string()).collect() read_source(
Locale::default(),
"\"1\",\"1b\",\"hl\",\"\",\"s\"\n\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\"\n",
)
.unwrap()
} }
fn destination() -> Destination { /// Every picker offers the file's columns by name *and* position, because
Destination { /// the name is what a person recognises and the position is what is stored.
table_name: "customers".to_string(),
columns: strings(&["name", "company_number", "active"]),
required: strings(&["name"]),
types: HashMap::new(),
}
}
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"),
}
}
/// The chooser starts in the table's own order, with nothing ticked.
#[test] #[test]
fn the_template_starts_in_table_order() { fn a_source_column_is_offered_by_name_and_position() {
let step = template_step(&ImportForm::default(), &destination()); let options = source_options(Locale::default(), &source());
assert_eq!(
template_rows(&step), assert_eq!(options.len(), 5);
vec![ assert_eq!(options[2].position, 3);
("name".to_string(), false), assert!(options[2].label.contains("hl"), "{}", options[2].label);
("company_number".to_string(), false), assert!(options[2].label.contains('3'), "{}", options[2].label);
("active".to_string(), false), assert_eq!(options[2].example, "value-a");
]
);
} }
/// Moving a row rearranges the chooser, and the generated header follows /// A file that left a column unnamed still has that column, and it is still
/// the arrangement rather than the table. /// selectable — by the one thing it does have.
#[test] #[test]
fn moving_a_row_rearranges_the_generated_header() { fn an_unnamed_source_column_is_offered_by_position_alone() {
let form = ImportForm { let options = source_options(Locale::default(), &source());
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::<Vec<_>>(),
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. assert!(options[3].label.contains('4'), "{}", options[3].label);
#[test] assert_eq!(options[3].example, "value-b");
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::<Vec<_>>(),
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());
} }
} }

View File

@@ -1,3 +1,4 @@
mod destination;
mod loader; mod loader;
mod logic; mod logic;
mod prepare; mod prepare;
@@ -24,6 +25,5 @@ pub(crate) fn router() -> Router<AppState> {
// to look at first. Both read the same preparation. // to look at first. Both read the same preparation.
.route("/admin/import", post(logic::import_csv)) .route("/admin/import", post(logic::import_csv))
.route("/admin/import/prepared.csv", post(logic::download_prepared)) .route("/admin/import/prepared.csv", post(logic::download_prepared))
.route("/admin/import/template.csv", post(logic::download_template))
.layer(DefaultBodyLimit::max(128 * 1024 * 1024)) .layer(DefaultBodyLimit::max(128 * 1024 * 1024))
} }

View File

@@ -1,15 +1,19 @@
//! Turning what the user uploaded into the one CSV the import understands. //! Turning the uploaded file plus the user's mapping into the one CSV the
//! import understands.
//! //!
//! Everything here is about *position*. A value lands in a column because the //! There is one shape of input: a header row, then data rows. The header is
//! user pointed position 3 at `active`, and for no other reason — not because //! read so the user can tell the file's columns apart, and is then done with —
//! a source header spells something similar, not because the words look alike. //! it is never imported, and it never decides anything. What decides is the
//! The source's own header, when it has one, is read out for orientation and //! mapping: destination column `a` takes source position 3, because the user
//! then thrown away. //! said so.
//!
//! Positions, not names, all the way through. A file may perfectly well have
//! two columns called `name`; the page shows them as `name — position 1` and
//! `name — position 2`, and what it stores is 1 and 2.
//! //!
//! The output is the canonical form: fully quoted CSV whose header names real //! 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 //! destination columns. From there the import is the strict one it always was —
//! import is the strict one it always was — types, validations, scripts, links //! types, validations, scripts, links and permissions are all the server's.
//! and permissions are all the server's, unchanged.
use std::collections::HashSet; use std::collections::HashSet;
@@ -17,210 +21,128 @@ use crate::{i18n::Locale, tr};
use super::super::common::csv::{parse_csv, write_record}; use super::super::common::csv::{parse_csv, write_record};
/// How the uploaded text is laid out. /// The uploaded file: its header, and its data.
///
/// 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<Self, String> {
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)] #[derive(Debug)]
pub(crate) struct Source { pub(crate) struct Source {
/// The source's own header row, kept only to label the mapping rows. It is /// The names the file gives its own columns. Shown to the user so they can
/// `None` for a data-only file, and it never decides anything. /// recognise the file; never matched against anything.
pub header: Option<Vec<String>>, pub header: Vec<String>,
pub rows: Vec<Vec<String>>, pub rows: Vec<Vec<String>>,
/// 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 { impl Source {
/// What the first data row holds at each position, for the mapping step to /// How many positions the file has. Every row has exactly these, which is
/// show beside the destination picker. An example is the one thing that /// what makes "position 3" mean one thing.
/// reliably tells a user which column they are looking at. pub(crate) fn width(&self) -> usize {
pub(crate) fn examples(&self) -> Vec<String> { self.header.len()
(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. /// The name the file gives position `index`, empty when it gave none.
pub(crate) fn source_name(&self, position: usize) -> Option<String> { pub(crate) fn name(&self, index: usize) -> &str {
self.header self.header.get(index).map(String::as_str).unwrap_or_default()
.as_ref() }
.and_then(|header| header.get(position))
.cloned() /// What the first data row holds at `index`. An example is the fastest way
/// to tell which column of a file you are looking at.
pub(crate) fn example(&self, index: usize) -> &str {
self.rows
.first()
.and_then(|row| row.get(index))
.map(String::as_str)
.unwrap_or_default()
} }
} }
/// Reads the uploaded text as `mode` says it is laid out. /// Reads the uploaded text: header row, then at least one data row, every row
pub(crate) fn read_source(locale: Locale, csv: &str, mode: SourceMode) -> Result<Source, String> { /// the same width.
pub(crate) fn read_source(locale: Locale, csv: &str) -> Result<Source, String> {
let mut rows = parse_csv(locale, csv)?; let mut rows = parse_csv(locale, csv)?;
let header = match mode { // One row is a header with nothing under it, which is not an import.
SourceMode::Header => { if rows.len() < 2 {
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")); return Err(tr!(locale, "import-err-no-data-rows"));
} }
let header = rows.remove(0);
let width = header if header.is_empty() {
.as_ref()
.map_or_else(|| rows[0].len(), |header| header.len());
if width == 0 {
return Err(tr!(locale, "import-err-empty")); return Err(tr!(locale, "import-err-empty"));
} }
// Positions only mean anything if every row has the same ones. A short row // 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. // would otherwise shift every value after the gap into the wrong column.
if let Some(row) = rows.iter().find(|row| row.len() != width) { if let Some(row) = rows.iter().find(|row| row.len() != header.len()) {
return Err(tr!( return Err(tr!(
locale, locale,
"import-err-row-width", "import-err-row-width",
"expected" => width as i64, "expected" => header.len() as i64,
"found" => row.len() as i64, "found" => row.len() as i64,
)); ));
} }
Ok(Source { header, rows })
Ok(Source { header, rows, width })
} }
/// Where each source position is to be written, `None` being "ignore". /// One destination column, and the source position its values come from.
#[derive(Debug)] #[derive(Debug, Eq, PartialEq)]
pub(crate) struct Mapping(Vec<Option<String>>); pub(crate) struct Assignment {
/// The destination's current name, which is what the prepared header says.
impl Mapping { pub column: String,
/// The destination chosen for a position, for the form to re-render with /// Index into a source row, from 0. The page numbers positions from 1.
/// the user's own answers still selected. pub source: usize,
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 /// Checks the mapping the user built and reduces it to the assignments that
/// proper place" true rather than hoped for. /// actually write something.
/// ///
/// `writable` is the destination list the table itself declares, loaded again /// `chosen` is one entry per writable destination column, in the order the
/// on every step: a column dropped or renamed while the form was open is caught /// mapping step rendered them, `None` meaning "do not import". A destination
/// here instead of writing values into a column that no longer means what the /// cannot appear twice because it only ever has one row, so what is left to
/// user chose. /// check is the source side.
pub(crate) fn read_mapping( pub(crate) fn read_mapping(
locale: Locale, locale: Locale,
posted: &[String], chosen: &[(String, Option<usize>)],
width: usize, source: &Source,
writable: &[String], ) -> Result<Vec<Assignment>, String> {
) -> Result<Mapping, String> {
// 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::<HashSet<_>>();
let mut used = HashSet::new(); let mut used = HashSet::new();
let mut targets = Vec::with_capacity(width); let mut assignments = Vec::new();
for (position, target) in posted.iter().enumerate() { for (column, position) in chosen {
let target = target.trim(); let Some(index) = position else { continue };
if target.is_empty() { // A position the file does not have — the CSV was edited after the
targets.push(None); // mapping was built, or the form was tampered with.
continue; if *index >= source.width() {
}
if !known.contains(target) {
return Err(tr!( return Err(tr!(
locale, locale,
"import-err-mapping-unknown-column", "import-err-source-position-missing",
"column" => target.to_string(), "position" => (*index + 1) as i64,
"position" => (position + 1) as i64, "column" => column.clone(),
"positions" => source.width() as i64,
)); ));
} }
// Two positions pointed at one column let whichever is read second // One source column feeding two destinations is more often a misclick
// decide the value, silently. Refused instead. // than an intention, and the page stops offering a position once it is
if !used.insert(target.to_string()) { // taken. Saying so beats copying a value into two columns quietly.
if !used.insert(*index) {
return Err(tr!( return Err(tr!(
locale, locale,
"import-err-mapping-duplicate", "import-err-source-used-twice",
"column" => target.to_string(), "position" => (*index + 1) as i64,
"name" => source.name(*index).to_string(),
)); ));
} }
targets.push(Some(target.to_string())); assignments.push(Assignment {
column: column.clone(),
source: *index,
});
} }
if used.is_empty() { if assignments.is_empty() {
return Err(tr!(locale, "import-err-mapping-empty")); return Err(tr!(locale, "import-err-mapping-empty"));
} }
Ok(Mapping(targets)) Ok(assignments)
} }
/// The canonical import, and what it leaves behind. /// The canonical import, and what it leaves behind.
pub(crate) struct Prepared { pub(crate) struct Prepared {
/// The destination header, in source-position order — which is the only /// The destination header, in the order the mapping step lists the columns.
/// order the data can be written in, since the values arrive in it.
pub columns: Vec<String>, pub columns: Vec<String>,
/// Data rows holding only the mapped positions, aligned to `columns`. /// Data rows holding only the mapped values, aligned to `columns`.
pub rows: Vec<Vec<String>>, pub rows: Vec<Vec<String>>,
/// The source positions sent nowhere, numbered from 1 as the mapping step /// The source positions no destination takes, from 1.
/// numbers them.
pub ignored: Vec<usize>, pub ignored: Vec<usize>,
/// Destination columns this import does not write. The server fills them /// Destination columns this import does not write. The server fills them
/// with their defaults, or refuses the row if it cannot. /// with their defaults, or refuses the row if it cannot.
@@ -232,8 +154,8 @@ impl Prepared {
self.rows.len() self.rows.len()
} }
/// The first `limit` rows, for the preview. A preview is for recognising /// The first `limit` rows. A preview is for recognising your own data, and
/// your own data, and nobody recognises it on row 40 000. /// nobody recognises it on row 40 000.
pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec<String>] { pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec<String>] {
&self.rows[..self.rows.len().min(limit)] &self.rows[..self.rows.len().min(limit)]
} }
@@ -243,42 +165,47 @@ impl Prepared {
} }
} }
/// Applies the mapping: the values that were pointed somewhere, under the names /// Applies the mapping: the values that were asked for, under the names they
/// they were pointed at. /// were asked for.
pub(crate) fn prepare(mapping: &Mapping, source: &Source, writable: &[String]) -> Prepared { ///
let taken = (0..mapping.len()) /// `writable` is every destination column the table offers, so the result can
.filter_map(|position| Some((position, mapping.target(position)?.to_string()))) /// say which ones this import leaves alone.
.collect::<Vec<_>>(); pub(crate) fn prepare(
assignments: &[Assignment],
let columns = taken source: &Source,
writable: &[String],
) -> Prepared {
let columns = assignments
.iter() .iter()
.map(|(_, column)| column.clone()) .map(|assignment| assignment.column.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let rows = source let rows = source
.rows .rows
.iter() .iter()
.map(|row| { .map(|row| {
taken assignments
.iter() .iter()
.map(|(position, _)| row.get(*position).cloned().unwrap_or_default()) .map(|assignment| row.get(assignment.source).cloned().unwrap_or_default())
.collect() .collect()
}) })
.collect(); .collect();
let ignored = (0..source.width) let taken = assignments
.filter(|position| mapping.target(*position).is_none())
.map(|position| position + 1)
.collect();
let omitted = writable
.iter() .iter()
.filter(|column| !columns.contains(column)) .map(|assignment| assignment.source)
.cloned() .collect::<HashSet<_>>();
.collect();
Prepared { Prepared {
ignored: (0..source.width())
.filter(|index| !taken.contains(index))
.map(|index| index + 1)
.collect(),
omitted: writable
.iter()
.filter(|column| !columns.contains(column))
.cloned()
.collect(),
columns, columns,
rows, rows,
ignored,
omitted,
} }
} }
@@ -294,39 +221,6 @@ pub(crate) fn canonical_csv(prepared: &Prepared) -> String {
csv 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<String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -335,101 +229,94 @@ mod tests {
values.iter().map(|value| value.to_string()).collect() values.iter().map(|value| value.to_string()).collect()
} }
fn writable() -> Vec<String> { /// The worked example: six source columns, three of them wanted.
strings(&["name", "company_number", "active", "note"]) fn example() -> Source {
read_source(
Locale::default(),
"\"1\",\"1b\",\"hl\",\"he\",\"s\",\"aa\"\n\
\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\",\"unused\"\n\
\"y1\",\"y2\",\"other-a\",\"other-b\",\"other-c\",\"unused\"\n",
)
.unwrap()
} }
/// 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] #[test]
fn a_data_only_file_keeps_its_first_row() { fn the_first_row_is_the_header_and_the_rest_is_data() {
let csv = "\"name\",\"num\"\n\"Acme\",\"12345678\"\n"; let source = example();
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap(); assert_eq!(source.width(), 6);
assert!(source.header.is_none());
assert_eq!(source.rows.len(), 2); assert_eq!(source.rows.len(), 2);
assert_eq!(source.examples(), strings(&["name", "num"])); assert_eq!(source.name(2), "hl");
assert_eq!(source.example(2), "value-a");
} }
/// A file declared to have a header loses its first row, and that row is /// A header with nothing under it is not an import, and says so rather than
/// 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. /// preparing an empty one.
#[test] #[test]
fn a_header_with_nothing_under_it_is_refused() { fn a_header_with_nothing_under_it_is_refused() {
let csv = "\"Company\",\"Registration number\"\n"; assert!(read_source(Locale::default(), "\"a\",\"b\"\n").is_err());
assert!(read_source(Locale::default(), csv, SourceMode::Header).is_err());
} }
/// Positions only mean something if every row has the same ones. /// Positions only mean something if every row has the same ones.
#[test] #[test]
fn rows_of_different_widths_are_refused() { fn rows_of_a_different_width_than_the_header_are_refused() {
let csv = "\"Acme\",\"12345678\"\n\"Example\"\n"; let error = read_source(Locale::default(), "\"a\",\"b\"\n\"only-one\"\n")
let error = read_source(Locale::default(), csv, SourceMode::Data)
.expect_err("a short row shifts every value after it"); .expect_err("a short row shifts every value after it");
assert!(error.contains('2') && error.contains('1'), "{error}"); assert!(error.contains('2') && error.contains('1'), "{error}");
} }
/// The heart of it: the user's choices decide where values go, source names /// The whole point, end to end: three destinations take three positions,
/// are irrelevant, and an ignored position takes its value nowhere. /// the other three source columns go nowhere.
#[test] #[test]
fn values_land_where_the_user_pointed_them() { fn only_the_mapped_destinations_are_prepared() {
let csv = "\"whatever\",\"old field 7\",\"enabled value\",\"legacy\"\n\ let source = example();
\"Acme\",\"12345678\",\"true\",\"drop me\"\n\ let writable = strings(&["a", "b", "c", "note"]);
\"Example\",\"87654321\",\"false\",\"drop me too\"\n"; let assignments = read_mapping(
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
let mapping = read_mapping(
Locale::default(), Locale::default(),
&strings(&["name", "company_number", "active", ""]), &[
source.width, ("a".to_string(), Some(2)),
&writable(), ("b".to_string(), Some(3)),
("c".to_string(), Some(4)),
("note".to_string(), None),
],
&source,
) )
.unwrap(); .unwrap();
let prepared = prepare(&mapping, &source, &writable()); let prepared = prepare(&assignments, &source, &writable);
assert_eq!( assert_eq!(prepared.columns, strings(&["a", "b", "c"]));
prepared.columns, assert_eq!(prepared.ignored, vec![1, 2, 6]);
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!(prepared.omitted, strings(&["note"]));
assert_eq!( assert_eq!(
canonical_csv(&prepared), canonical_csv(&prepared),
"\"name\",\"company_number\",\"active\"\n\ "\"a\",\"b\",\"c\"\n\
\"Acme\",\"12345678\",\"true\"\n\ \"value-a\",\"value-b\",\"value-c\"\n\
\"Example\",\"87654321\",\"false\"\n" \"other-a\",\"other-b\",\"other-c\"\n"
); );
} }
/// Out-of-order and sparse mappings are the normal case, not an edge one: /// Destinations may take their values in any order, and out of order is the
/// the header follows the source's positions, because the values do. /// normal case — the prepared header follows the destination rows, and each
/// row's values follow it.
#[test] #[test]
fn the_prepared_header_follows_the_source_order() { fn a_destination_may_take_any_position() {
let csv = "\"Acme\",\"unused legacy value\",\"true\",\"12345678\"\n"; let source = example();
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap(); let assignments = read_mapping(
let mapping = read_mapping(
Locale::default(), Locale::default(),
&strings(&["name", "", "active", "company_number"]), &[
source.width, ("a".to_string(), Some(5)),
&writable(), ("b".to_string(), None),
("c".to_string(), Some(0)),
],
&source,
) )
.unwrap(); .unwrap();
let prepared = prepare(&mapping, &source, &writable()); let prepared = prepare(&assignments, &source, &strings(&["a", "b", "c"]));
assert_eq!( assert_eq!(
canonical_csv(&prepared), canonical_csv(&prepared),
"\"name\",\"active\",\"company_number\"\n\"Acme\",\"true\",\"12345678\"\n" "\"a\",\"c\"\n\"unused\",\"x1\"\n\"unused\",\"y1\"\n"
); );
assert_eq!(prepared.ignored, vec![2]); assert_eq!(prepared.omitted, strings(&["b"]));
} }
/// What is downloaded and what is imported are the same file. The import /// What is downloaded and what is imported are the same file. The import
@@ -437,129 +324,79 @@ mod tests {
/// that the two cannot drift apart. /// that the two cannot drift apart.
#[test] #[test]
fn the_downloaded_csv_parses_back_to_the_rows_that_get_imported() { 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(
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
let mapping = read_mapping(
Locale::default(), Locale::default(),
&strings(&["name", "note"]), "\"name\",\"note\"\n\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n",
source.width,
&writable(),
) )
.unwrap(); .unwrap();
let prepared = prepare(&mapping, &source, &writable()); let assignments = read_mapping(
Locale::default(),
&[("name".to_string(), Some(0)), ("note".to_string(), Some(1))],
&source,
)
.unwrap();
let prepared = prepare(&assignments, &source, &strings(&["name", "note"]));
let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap(); let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap();
assert_eq!(reparsed[0], prepared.columns); assert_eq!(reparsed[0], prepared.columns);
assert_eq!(reparsed[1..], prepared.rows[..]); assert_eq!(reparsed[1..], prepared.rows[..]);
} }
/// One destination cannot be filled from two positions: the second would /// A file may name two columns the same. Nothing here cares: the mapping is
/// silently win. /// positions, and the two are 1 and 2.
#[test] #[test]
fn a_destination_cannot_be_chosen_twice() { fn duplicate_source_names_are_not_ambiguous() {
let error = read_mapping( let source = read_source(
Locale::default(), Locale::default(),
&strings(&["name", "name"]), "\"name\",\"name\",\"status\"\n\"first\",\"second\",\"live\"\n",
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(); .unwrap();
let prepared = prepare(&mapping, &source, &writable()); let assignments = read_mapping(
assert_eq!(prepared.columns, strings(&["name", "active"])); Locale::default(),
assert_eq!(prepared.rows, vec![strings(&["Acme", "true"])]); &[("a".to_string(), Some(1)), ("b".to_string(), Some(0))],
&source,
)
.unwrap();
let prepared = prepare(&assignments, &source, &strings(&["a", "b"]));
assert_eq!(prepared.rows, vec![strings(&["second", "first"])]);
} }
/// One source column feeding two destinations is refused: the page stops
/// offering a position once it is taken, so arriving here means something
/// went wrong rather than that the user meant it.
#[test] #[test]
fn a_source_mode_round_trips_and_an_unknown_one_is_refused() { fn a_source_position_cannot_be_used_twice() {
for mode in [SourceMode::Header, SourceMode::Data, SourceMode::Template] { let source = example();
assert_eq!(SourceMode::parse(Locale::default(), mode.as_str()), Ok(mode)); let error = read_mapping(
} Locale::default(),
assert!(SourceMode::parse(Locale::default(), "guess").is_err()); &[("a".to_string(), Some(2)), ("b".to_string(), Some(2))],
&source,
)
.expect_err("position 3 cannot fill both");
assert!(error.contains("hl") && error.contains('3'), "{error}");
}
/// A position the file does not have is refused rather than read as blank.
#[test]
fn a_position_past_the_end_of_the_file_is_refused() {
let source = example();
let error = read_mapping(Locale::default(), &[("a".to_string(), Some(9))], &source)
.expect_err("the file has six positions");
assert!(error.contains("10") && error.contains('6'), "{error}");
}
/// Mapping nothing is not an import.
#[test]
fn a_mapping_that_writes_nothing_is_refused() {
let source = example();
assert!(
read_mapping(
Locale::default(),
&[("a".to_string(), None), ("b".to_string(), None)],
&source
)
.is_err()
);
} }
} }

View File

@@ -1,54 +1,37 @@
use crate::{i18n::Locale, tr}; use crate::{i18n::Locale, tr};
use super::prepare::SourceMode;
/// Every field the import form carries, at every step. /// Every field the import form carries, at every step.
/// ///
/// The whole preparation is one form posted back and forth: the page keeps no /// 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 /// server-side draft, so each step re-states the answers the next one needs and
/// and the user can go back without losing what they chose. /// the user can go back without losing what they chose.
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ImportForm { pub(crate) struct ImportForm {
#[serde(default)] #[serde(default)]
pub profile_name: String, pub profile_name: String,
/// One table. An import writes into one table, and a file for two tables is /// 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. /// two prepared imports.
#[serde(default)] #[serde(default)]
pub table_name: String, pub table_name: String,
/// `header`, `data` or `template` — see [`SourceMode`]. Kept as text so an /// The uploaded file, header row and all.
/// unrecognised value is a message rather than a form rejection.
#[serde(default)]
pub source_mode: String,
#[serde(default)] #[serde(default)]
pub csv_data: String, pub csv_data: String,
/// The destination chosen for each source position, in position order, one /// One entry per writable destination column, in the order the mapping step
/// entry per position; empty means "ignore". Posted once per `<select>`, /// renders them: the column's stable identity, not its name. See
/// which only `axum_extra`'s `Form` decodes into a `Vec`. /// [`DestinationKey`](super::destination::DestinationKey).
///
/// Posted once per row, which only `axum_extra`'s `Form` decodes into a
/// `Vec`.
#[serde(default)] #[serde(default)]
pub mapping: Vec<String>, pub destination: Vec<String>,
/// Every destination column in the order the template step has them /// The source position chosen for the destination at the same index, from
/// arranged, chosen or not, so the arrangement survives a re-render. /// 1; empty means "do not import". The two vectors are filled in document
/// order, so they line up row for row.
#[serde(default)] #[serde(default)]
pub template_order: Vec<String>, pub source_position: Vec<String>,
/// The columns ticked in the template step, which the checkboxes post in
/// the arranged order — so this *is* the generated header.
#[serde(default)]
pub template_columns: Vec<String>,
/// `up` or `down` when a move button posted, with `index` naming the row.
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub index: Option<usize>,
} }
impl ImportForm { impl ImportForm {
pub(crate) fn mode(&self, locale: Locale) -> Result<SourceMode, String> {
if self.source_mode.is_empty() {
return Ok(SourceMode::default());
}
SourceMode::parse(locale, &self.source_mode)
}
/// The scope and table this import writes into, checked for being answered /// The scope and table this import writes into, checked for being answered
/// at all. Whether they are the user's to write into is the catalog's /// at all. Whether they are the user's to write into is the catalog's
/// answer, and then the backend's. /// answer, and then the backend's.
@@ -61,7 +44,7 @@ impl ImportForm {
if table.is_empty() { if table.is_empty() {
return Err(tr!(locale, "import-err-tables-required")); return Err(tr!(locale, "import-err-tables-required"));
} }
if self.mode(locale)?.reads_a_file() && self.csv_data.trim().is_empty() { if self.csv_data.trim().is_empty() {
return Err(tr!(locale, "import-err-csv-required")); return Err(tr!(locale, "import-err-csv-required"));
} }
Ok((profile.to_string(), table.to_string())) Ok((profile.to_string(), table.to_string()))
@@ -72,81 +55,80 @@ impl ImportForm {
self.table_name == name self.table_name == name
} }
/// Whether the radio for `mode` is the one selected, for re-rendering the /// The mapping as posted: the destination key of each row, and the source
/// choice the user made. /// position it was given.
pub(crate) fn is_mode(&self, mode: &str) -> bool { ///
if self.source_mode.is_empty() { /// A row whose position is blank, unparseable or zero is "do not import".
return mode == SourceMode::default().as_str(); /// Positions are one-based on the page and zero-based here, since what they
} /// index is a row of the file.
self.source_mode == mode pub(crate) fn rows(&self) -> Vec<(&str, Option<usize>)> {
self.destination
.iter()
.enumerate()
.map(|(index, key)| {
let position = self
.source_position
.get(index)
.and_then(|raw| raw.trim().parse::<usize>().ok())
.and_then(|position| position.checked_sub(1));
(key.as_str(), position)
})
.collect()
} }
} }
/// Which of the preparation's steps the page is showing. /// Which of the preparation's steps the page is showing.
/// ///
/// One page, three stops: say what the source is, say where each position goes, /// One page, three stops: say where the data is going and hand over the file,
/// then look at the result before anything is written. /// say where each destination column's value comes from, then look at the
/// result before anything is written.
pub(crate) enum Step { pub(crate) enum Step {
/// Choose the destination table and describe the source.
Source, Source,
/// Point each source position at a destination column.
Mapping(MappingStep), Mapping(MappingStep),
/// Pick and arrange destination columns, and take away the header.
Template(TemplateStep),
/// The prepared import, as it will be sent.
Preview(PreviewStep), Preview(PreviewStep),
} }
pub(crate) struct MappingStep { pub(crate) struct MappingStep {
pub table_name: String, pub table_name: String,
/// The destinations to offer, in the order the table declares them. /// One row per writable destination column. A destination cannot be filled
pub columns: Vec<String>, /// twice because it appears exactly once.
pub rows: Vec<MappingRow>, pub rows: Vec<MappingRow>,
/// The file's columns, as they are offered in every picker.
pub sources: Vec<SourceOption>,
pub source_rows: usize, pub source_rows: usize,
/// Whether the source stated a header, so the table can show that column at
/// all — and say that it decides nothing.
pub has_source_names: bool,
} }
/// One source position, and what the user has pointed it at. /// One destination column, asking where its value comes from.
pub(crate) struct MappingRow { pub(crate) struct MappingRow {
/// Numbered from 1, the way the source is read by a person. /// The stable identity, for the hidden field.
pub position: usize, pub key: String,
/// What the source called this position, when it said. Orientation only. /// The name the table shows, and the name the prepared header will use.
pub source_name: Option<String>, pub name: String,
/// The first data row's value here — usually the fastest way to recognise pub required: bool,
/// which column this is. /// The chosen source position as the form carries it — one-based, empty for
/// "do not import".
pub chosen: String,
/// The first data row's value at the chosen position, so the row shows what
/// it is actually going to import.
pub example: String, pub example: String,
/// The chosen destination, empty for "ignore".
pub target: String,
} }
impl MappingRow { impl MappingRow {
/// Whether this position is pointed at `column`, for re-rendering the /// Whether this row takes `option`'s position, for re-rendering the picker
/// picker with the user's own answer selected. /// with the user's own answer selected.
pub(crate) fn targets(&self, column: &str) -> bool { pub(crate) fn takes(&self, option: &SourceOption) -> bool {
self.target == column self.chosen == option.position.to_string()
} }
} }
pub(crate) struct TemplateStep { /// One column of the uploaded file, as the pickers offer it.
pub table_name: String, pub(crate) struct SourceOption {
pub rows: Vec<TemplateRow>, /// One-based, which is how the page counts and how the form posts.
/// The generated header as it stands, empty when nothing is ticked. pub position: usize,
pub header: String, /// `hl — position 3`, or just the position when the file left the name
pub chosen: usize, /// blank. Built in Rust so it is translated once.
} pub label: String,
pub example: String,
pub(crate) struct TemplateRow {
/// Position in the arrangement, from 0, for the move buttons.
pub index: usize,
pub name: String,
pub chosen: bool,
/// Declared `NOT NULL`, so leaving it out of the template is likely to
/// produce rows the server refuses.
pub required: bool,
pub first: bool,
pub last: bool,
} }
pub(crate) struct PreviewStep { pub(crate) struct PreviewStep {
@@ -156,8 +138,8 @@ pub(crate) struct PreviewStep {
/// Rows the preview does not show, so a large file says how large. /// Rows the preview does not show, so a large file says how large.
pub hidden_rows: usize, pub hidden_rows: usize,
pub source_rows: usize, pub source_rows: usize,
/// Source positions written nowhere, labelled with the source's own name /// The file's columns no destination takes, labelled the way the pickers
/// when it gave one. /// labelled them.
pub ignored: Vec<String>, pub ignored: Vec<String>,
/// Destination columns this import does not write. /// Destination columns this import does not write.
pub omitted: Vec<String>, pub omitted: Vec<String>,
@@ -180,35 +162,84 @@ pub(crate) struct ImportPageState {
mod tests { mod tests {
use super::*; use super::*;
/// A template needs no file, so the form does not insist on one — and the
/// other two modes do.
#[test] #[test]
fn only_a_file_import_needs_a_file() { fn a_target_needs_a_scope_a_table_and_a_file() {
let form = ImportForm { let complete = ImportForm {
profile_name: "acme".to_string(), profile_name: "acme".to_string(),
table_name: "customers".to_string(), table_name: "customers".to_string(),
source_mode: "template".to_string(), csv_data: "\"a\"\n\"1\"\n".to_string(),
..ImportForm::default() ..ImportForm::default()
}; };
assert_eq!( assert_eq!(
form.target(Locale::default()).unwrap(), complete.target(Locale::default()).unwrap(),
("acme".to_string(), "customers".to_string()) ("acme".to_string(), "customers".to_string())
); );
let form = ImportForm { for missing in [
source_mode: "data".to_string(), ImportForm {
..form profile_name: String::new(),
}; ..complete.clone()
assert!(form.target(Locale::default()).is_err()); },
ImportForm {
table_name: String::new(),
..complete.clone()
},
ImportForm {
csv_data: " ".to_string(),
..complete.clone()
},
] {
assert!(missing.target(Locale::default()).is_err());
}
} }
/// The default is a file with a header, and the radio renders as such /// The two posted vectors line up row for row, and the page's one-based
/// before anything has been posted. /// positions become the zero-based indexes a row is read with.
#[test] #[test]
fn the_source_mode_defaults_to_a_header_file() { fn the_posted_rows_pair_up_and_lose_their_offset() {
let form = ImportForm::default(); let form = ImportForm {
assert_eq!(form.mode(Locale::default()).unwrap(), SourceMode::Header); destination: vec![
assert!(form.is_mode("header")); "id:42".to_string(),
assert!(!form.is_mode("data")); "id:57".to_string(),
"system:deleted".to_string(),
],
source_position: vec!["3".to_string(), String::new(), "1".to_string()],
..ImportForm::default()
};
assert_eq!(
form.rows(),
vec![
("id:42", Some(2)),
("id:57", None),
("system:deleted", Some(0)),
]
);
}
/// Anything that is not a position is "do not import" rather than a guess.
/// Zero is not a position either: the page counts from 1.
#[test]
fn an_unusable_position_means_do_not_import() {
let form = ImportForm {
destination: vec!["id:1".to_string(), "id:2".to_string(), "id:3".to_string()],
source_position: vec!["0".to_string(), "x".to_string(), "-1".to_string()],
..ImportForm::default()
};
assert!(form.rows().iter().all(|(_, position)| position.is_none()));
}
/// A row with no position posted at all is still a row, so the pairing
/// cannot slide by one.
#[test]
fn a_missing_position_does_not_shift_the_rows() {
let form = ImportForm {
destination: vec!["id:1".to_string(), "id:2".to_string()],
source_position: vec!["2".to_string()],
..ImportForm::default()
};
assert_eq!(form.rows(), vec![("id:1", Some(1)), ("id:2", None)]);
} }
} }

View File

@@ -74,7 +74,7 @@ mod tests {
use crate::pages::import_export::common::loader::{Catalog, Profile}; use crate::pages::import_export::common::loader::{Catalog, Profile};
use super::super::state::{ use super::super::state::{
ImportForm, MappingRow, MappingStep, PreviewStep, TemplateRow, TemplateStep, ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption,
}; };
fn strings(values: &[&str]) -> Vec<String> { fn strings(values: &[&str]) -> Vec<String> {
@@ -99,127 +99,113 @@ mod tests {
form: ImportForm { form: ImportForm {
profile_name: "acme".to_string(), profile_name: "acme".to_string(),
table_name: "customers".to_string(), table_name: "customers".to_string(),
source_mode: "header".to_string(), csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
csv_data: "\"Company\"\n\"Acme\"\n".to_string(), destination: strings(&["id:42", "id:57"]),
mapping: strings(&["name"]), source_position: strings(&["1", ""]),
..ImportForm::default()
}, },
step, step,
} }
} }
/// The first step asks where the data goes and how the source is laid out, fn mapping() -> Step {
/// and asks the second question outright — the page never decides for Step::Mapping(MappingStep {
/// itself whether the first row is a header. table_name: "customers".to_string(),
rows: vec![
MappingRow {
key: "id:42".to_string(),
name: "a".to_string(),
required: true,
chosen: "1".to_string(),
example: "value-a".to_string(),
},
MappingRow {
key: "id:57".to_string(),
name: "b".to_string(),
required: false,
chosen: String::new(),
example: String::new(),
},
],
sources: vec![
SourceOption {
position: 1,
label: "hl \u{2014} position 1".to_string(),
example: "value-a".to_string(),
},
SourceOption {
position: 2,
label: "he \u{2014} position 2".to_string(),
example: "value-b".to_string(),
},
],
source_rows: 1,
})
}
/// The first step asks for a destination and a file, and nothing else —
/// there is one shape of input, so there is nothing to choose about it.
#[test] #[test]
fn the_first_step_asks_how_the_source_is_laid_out() { fn the_first_step_asks_for_a_table_and_a_file() {
let html = render_page(&page(Step::Source)); let html = render_page(&page(Step::Source));
for mode in ["header", "data", "template"] {
assert!(
html.contains(&format!(r#"name="source_mode" value="{mode}""#)),
"{html}"
);
}
assert!(html.contains(r#"name="table_name""#), "{html}"); assert!(html.contains(r#"name="table_name""#), "{html}");
assert!(html.contains(r#"name="csv_data""#), "{html}"); assert!(html.contains(r#"name="csv_data""#), "{html}");
assert!(!html.contains("source_mode"), "{html}");
} }
/// The mapping step is one picker per source position, and every picker /// The mapping is read destination-first: one row per column of the table,
/// offers Ignore alongside the table's own columns. The source's own name /// each asking where its value comes from. A destination cannot be filled
/// is shown next to it and is not what any option is built from. /// twice because it appears exactly once.
#[test] #[test]
fn the_mapping_step_offers_a_destination_for_every_position() { fn the_mapping_step_asks_every_destination_column_once() {
let html = render_step(&page(Step::Mapping(MappingStep { let html = render_step(&page(mapping()));
table_name: "customers".to_string(),
columns: strings(&["name", "company_number"]),
rows: vec![
MappingRow {
position: 1,
source_name: Some("Company".to_string()),
example: "Acme".to_string(),
target: "name".to_string(),
},
MappingRow {
position: 2,
source_name: Some("Internal note".to_string()),
example: "old customer".to_string(),
target: String::new(),
},
],
source_rows: 2,
has_source_names: true,
})));
assert_eq!(html.matches(r#"name="mapping""#).count(), 2); assert_eq!(html.matches(r#"name="source_position""#).count(), 2);
assert!(html.contains(r#"<option value="name" selected>"#), "{html}"); assert_eq!(html.matches(r#"name="destination""#).count(), 2);
assert!(html.contains("Internal note"), "{html}"); assert!(html.contains("<code>a</code>"), "{html}");
assert!(html.contains("old customer"), "{html}"); assert!(html.contains("<code>b</code>"), "{html}");
// The destination and the source it came from are carried forward, so // The file's columns are offered by name and position together.
// going back does not lose the file. assert!(html.contains("hl \u{2014} position 1"), "{html}");
assert!(html.contains(r#"name="csv_data""#), "{html}"); assert!(html.contains(r#"<option value="1" data-example="value-a" selected>"#), "{html}");
// Nothing is mapped unless the user said so.
assert!(html.contains(">Do not import</option>"), "{html}");
} }
/// The template step is a chooser with an arrangement, and it shows the /// The destination travels as its stable identity, never as its name, so a
/// header it would generate rather than describing it. /// rename between the mapping and the import moves with the column.
#[test] #[test]
fn the_template_step_shows_the_header_it_generates() { fn a_destination_is_carried_as_its_identity() {
let html = render_step(&page(Step::Template(TemplateStep { let html = render_step(&page(mapping()));
table_name: "customers".to_string(),
rows: vec![
TemplateRow {
index: 0,
name: "name".to_string(),
chosen: true,
required: true,
first: true,
last: false,
},
TemplateRow {
index: 1,
name: "note".to_string(),
chosen: false,
required: false,
first: false,
last: true,
},
],
header: "\"name\"\n".to_string(),
chosen: 1,
})));
assert!(html.contains("&#34;name&#34;"), "{html}"); assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
assert_eq!(html.matches(r#"name="template_order""#).count(), 2); assert!(!html.contains(r#"name="destination" value="a""#), "{html}");
assert!(html.contains(r#"formaction="/admin/import/template.csv""#), "{html}");
// The ends of the arrangement have nowhere to move to.
assert_eq!(html.matches("disabled").count(), 2);
} }
/// The preview shows the values under the columns they will actually be /// The preview shows the values under the columns they will be written to,
/// written to, says what is being left out, and offers both exits. /// says what is being left out, and offers both exits.
#[test] #[test]
fn the_preview_shows_the_prepared_import_and_both_ways_out() { fn the_preview_shows_the_prepared_import_and_both_ways_out() {
let html = render_step(&page(Step::Preview(PreviewStep { let html = render_step(&page(Step::Preview(PreviewStep {
table_name: "customers".to_string(), table_name: "customers".to_string(),
columns: strings(&["name", "active"]), columns: strings(&["a", "b", "c"]),
rows: vec![strings(&["Acme", "true"])], rows: vec![strings(&["value-a", "value-b", "value-c"])],
hidden_rows: 3, hidden_rows: 3,
source_rows: 4, source_rows: 4,
ignored: strings(&["2 (Internal note)"]), ignored: strings(&["1 \u{2014} position 1", "aa \u{2014} position 6"]),
omitted: strings(&["note"]), omitted: strings(&["note"]),
missing_required: strings(&["company_number"]), missing_required: strings(&["number"]),
csv: "\"name\",\"active\"\n\"Acme\",\"true\"\n".to_string(), csv: "\"a\",\"b\",\"c\"\n".to_string(),
}))); })));
assert!(html.contains("<th>name</th>"), "{html}"); assert!(html.contains("<th>a</th>"), "{html}");
assert!(html.contains("<td>Acme</td>"), "{html}"); assert!(html.contains("<td>value-a</td>"), "{html}");
assert!(html.contains("2 (Internal note)"), "{html}"); assert!(html.contains("aa \u{2014} position 6"), "{html}");
assert!(html.contains("company_number"), "{html}"); assert!(html.contains("number"), "{html}");
// Import, and download the very same file.
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}"); assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
assert!(html.contains("&#34;name&#34;,&#34;active&#34;"), "{html}"); assert!(html.contains("&#34;a&#34;,&#34;b&#34;,&#34;c&#34;"), "{html}");
// The mapping travels with it, so the download and the import prepare // The mapping travels with it, so the download and the import prepare
// the identical file. // the identical file.
assert!(html.contains(r#"name="mapping" value="name""#), "{html}"); assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
assert!(html.contains(r#"name="source_position" value="1""#), "{html}");
} }
} }

View File

@@ -317,21 +317,13 @@
/* ---------- Import preparation (pages/import_export/import) ---------- */ /* ---------- Import preparation (pages/import_export/import) ---------- */
/* The one question the page must not answer for the user: whether the first /* A mapping row is read left to right as a sentence: the column `a` takes its
row is a header. It is a fieldset rather than a row of loose checkboxes so value from "hl — position 3", which on the first row holds "value-a". The
the three answers read as one choice. */ example is what people actually recognise their data by, so it gets the
.source-mode { margin: 0; border: 1px solid #e1e6ee; border-radius: 8px; padding: 12px 14px; background: #fbfcfe; } room, and it is the last thing on the line because it is the confirmation
.source-mode legend { padding: 0 6px; color: #33415c; font-size: 13px; font-weight: 600; } rather than the choice. */
.source-mode .check { margin: 4px 0; font-size: 13px; color: #33415c; } .mapping-table select { width: 100%; min-width: 220px; }
.source-mode .hint { margin: 8px 0 0; font-size: 12px; }
/* A mapping row is read left to right as a sentence: position 3, which holds
"12345678", goes to company_number. The example is what people actually
recognise their data by, so it gets the room. */
.mapping-table .source-name { color: #7a8496; font-size: 12px; }
.mapping-table .example { max-width: 320px; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #33415c; } .mapping-table .example { max-width: 320px; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #33415c; }
.mapping-table select { width: 100%; min-width: 180px; }
.template-table .check { justify-content: center; }
/* A secondary action in a form's action row — "back a step". The row's own /* A secondary action in a form's action row — "back a step". The row's own
rule paints every button as the primary one, so this has to say otherwise rule paints every button as the primary one, so this has to say otherwise

View File

@@ -1,5 +1,5 @@
{# {#
Two things the mapping form does in the browser, both of them conveniences Two things the mapping table does in the browser, both of them conveniences
over rules the server enforces anyway. over rules the server enforces anyway.
Everything is delegated from `document`, because each step swaps the whole Everything is delegated from `document`, because each step swaps the whole
@@ -7,38 +7,28 @@
#} #}
<script> <script>
(function () { (function () {
// A template is generated from the table, so there is no file to give. function sync() {
// Hiding the fields is not the check — the server refuses a file import const selects = Array.from(document.querySelectorAll("[data-source-select]"));
// with no CSV either way. // A source column can fill one destination only. The mapping refuses a
function syncSourceFields() { // repeat outright; this stops it being offered, so the refusal is not
const mode = document.querySelector("[data-source-mode]:checked"); // how the user finds out.
const fields = document.querySelector("[data-source-fields]");
if (!mode || !fields) return;
fields.hidden = mode.value === "template";
}
// A destination column can be filled from one source position only. The
// mapping step refuses a repeat outright; this stops it being offered, so
// the refusal is not how the user finds out.
function syncDestinations() {
const selects = Array.from(document.querySelectorAll("[data-destination]"));
const taken = new Set(selects.map((select) => select.value).filter(Boolean)); const taken = new Set(selects.map((select) => select.value).filter(Boolean));
for (const select of selects) { for (const select of selects) {
for (const option of select.options) { for (const option of select.options) {
option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value); option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value);
} }
// The example beside the row is the first data row's value at the
// chosen position, so the row shows what it is actually going to
// import rather than only which column it named.
const cell = select.closest("tr")?.querySelector("[data-example-cell]");
if (cell) cell.textContent = select.selectedOptions[0]?.dataset.example ?? "";
} }
} }
document.addEventListener("change", function (event) { document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-mode]")) syncSourceFields(); if (event.target.matches("[data-source-select]")) sync();
if (event.target.matches("[data-destination]")) syncDestinations();
}); });
document.body.addEventListener("htmx:afterSwap", function () { document.body.addEventListener("htmx:afterSwap", sync);
syncSourceFields(); sync();
syncDestinations();
});
syncSourceFields();
syncDestinations();
})(); })();
</script> </script>

View File

@@ -2,12 +2,11 @@
What the first step answered, carried forward. What the first step answered, carried forward.
Steps 2 and 3 replace the fields that hold these, so they re-state them: the Steps 2 and 3 replace the fields that hold these, so they re-state them: the
form is the whole state of the preparation, and the destination and the source form is the whole state of the preparation, and the destination and the file
text have to survive every swap. The summary line above them is so the user have to survive every swap. The summary line above them is so the user can
can still see what they are preparing after the selects are gone. still see what they are preparing after the selects are gone.
#} #}
<p class="hint">{{ nav.tr_args("import-carried", [("scope", page.form.profile_name.clone()), ("table", page.form.table_name.clone())]) }}</p> <p class="hint">{{ nav.tr_args("import-carried", [("scope", page.form.profile_name.clone()), ("table", page.form.table_name.clone())]) }}</p>
<input type="hidden" name="profile_name" value="{{ page.form.profile_name }}"> <input type="hidden" name="profile_name" value="{{ page.form.profile_name }}">
<input type="hidden" name="table_name" value="{{ page.form.table_name }}"> <input type="hidden" name="table_name" value="{{ page.form.table_name }}">
<input type="hidden" name="source_mode" value="{{ page.form.source_mode }}">
<input type="hidden" name="csv_data" value="{{ page.form.csv_data }}"> <input type="hidden" name="csv_data" value="{{ page.form.csv_data }}">

View File

@@ -11,7 +11,7 @@
{% match page.step %} {% match page.step %}
{#- ------------------------------------------------------------------ {#- ------------------------------------------------------------------
Step 1 — where the data is going, and how the source is laid out. Step 1 — where the data is going, and the file it comes from.
------------------------------------------------------------------ -#} ------------------------------------------------------------------ -#}
{% when Step::Source %} {% when Step::Source %}
<div class="form-grid"> <div class="form-grid">
@@ -31,33 +31,12 @@
{%- endfor %}{% endfor %} {%- endfor %}{% endfor %}
</select> </select>
</label> </label>
<label class="wide">{{ nav.tr("import-csv-file") }}
{# <input type="file" accept=".csv,text/csv"
Always asked, never guessed. A data row can hold words that read exactly onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
like column names, so deciding for the user is how a real row gets eaten </label>
as a header. <label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="12">{{ page.form.csv_data }}</textarea></label>
#} <small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
<fieldset class="wide source-mode">
<legend>{{ nav.tr("import-source-mode") }}</legend>
<label class="check"><input type="radio" name="source_mode" value="header" data-source-mode
{% if page.form.is_mode("header") %} checked{% endif %}>{{ nav.tr("import-source-header") }}</label>
<label class="check"><input type="radio" name="source_mode" value="data" data-source-mode
{% if page.form.is_mode("data") %} checked{% endif %}>{{ nav.tr("import-source-data") }}</label>
<label class="check"><input type="radio" name="source_mode" value="template" data-source-mode
{% if page.form.is_mode("template") %} checked{% endif %}>{{ nav.tr("import-source-template") }}</label>
<p class="hint">{{ nav.tr("import-source-mode-hint") }}</p>
</fieldset>
<div class="wide" data-source-fields>
<div class="form-grid">
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="12">{{ page.form.csv_data }}</textarea></label>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a> <a href="/">{{ nav.tr("common-cancel") }}</a>
@@ -65,7 +44,9 @@
</div> </div>
{#- ------------------------------------------------------------------ {#- ------------------------------------------------------------------
Step 2a — one row per source position, pointed wherever the user says. Step 2 — one row per destination column, asking where its value comes
from. Read this way round, a destination cannot be filled twice: it
appears exactly once.
------------------------------------------------------------------ -#} ------------------------------------------------------------------ -#}
{% when Step::Mapping with (step) %} {% when Step::Mapping with (step) %}
{% include "pages/import_export/import/carried.html" %} {% include "pages/import_export/import/carried.html" %}
@@ -76,26 +57,32 @@
<table class="builder-table mapping-table"> <table class="builder-table mapping-table">
<thead> <thead>
<tr> <tr>
<th>{{ nav.tr("import-th-position") }}</th>
{% if step.has_source_names %}<th>{{ nav.tr("import-th-source-name") }}</th>{% endif %}
<th>{{ nav.tr("import-th-example") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th> <th>{{ nav.tr("import-th-destination") }}</th>
<th>{{ nav.tr("import-th-required") }}</th>
<th>{{ nav.tr("import-th-source") }}</th>
<th>{{ nav.tr("import-th-example") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in step.rows %} {% for row in step.rows %}
<tr> <tr>
<td class="mark">{{ row.position }}</td> <td><code>{{ row.name }}</code></td>
{% if step.has_source_names %}<td class="source-name">{% if let Some(name) = row.source_name %}{{ name }}{% endif %}</td>{% endif %} <td>{% if row.required %}{{ nav.tr("common-yes") }}{% else %}<span class="hint">{{ nav.tr("common-no") }}</span>{% endif %}</td>
<td class="example">{{ row.example }}</td>
<td> <td>
<select name="mapping" data-destination> {#
<option value="">{{ nav.tr("import-destination-ignore") }}</option> The destination travels as its stable identity, so a rename
{% for column in step.columns %} between here and Import moves the mapping with the column
<option value="{{ column }}"{% if row.targets(column) %} selected{% endif %}>{{ column }}</option> instead of leaving it pointing at a name.
#}
<input type="hidden" name="destination" value="{{ row.key }}">
<select name="source_position" data-source-select>
<option value="">{{ nav.tr("import-source-none") }}</option>
{% for option in step.sources %}
<option value="{{ option.position }}" data-example="{{ option.example }}"{% if row.takes(option) %} selected{% endif %}>{{ option.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td class="example" data-example-cell>{{ row.example }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -106,69 +93,17 @@
<button type="button" hx-post="/admin/import/preview" hx-include="closest form">{{ nav.tr("import-to-preview") }}</button> <button type="button" hx-post="/admin/import/preview" hx-include="closest form">{{ nav.tr("import-to-preview") }}</button>
</div> </div>
{#- ------------------------------------------------------------------
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" %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-template-heading", [("table", step.table_name.clone())]) }}</h2>
<p class="hint">{{ nav.tr("import-template-hint") }}</p>
<table class="builder-table template-table">
<thead>
<tr>
<th>{{ nav.tr("import-th-order") }}</th>
<th>{{ nav.tr("import-th-include") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th>
</tr>
</thead>
<tbody>
{% for row in step.rows %}
<tr>
<td class="order">
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-up", [("name", row.name.clone())]) }}"
{% if row.first %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "up", "index": "{{ row.index }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-down", [("name", row.name.clone())]) }}"
{% if row.last %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "down", "index": "{{ row.index }}"}'{% endif %}>&darr;</button>
</td>
<td>
{#
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.
#}
<input type="hidden" name="template_order" value="{{ row.name }}">
<label class="check"><input type="checkbox" name="template_columns" value="{{ row.name }}"{% if row.chosen %} checked{% endif %}
hx-post="/admin/import/prepare" hx-include="closest form"></label>
</td>
<td><code>{{ row.name }}</code>{% if row.required %} <span class="hint">{{ nav.tr("import-required-column") }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if step.chosen > 0 %}
<p class="hint">{{ nav.tr("import-template-generated") }}</p>
<pre class="sql-preview">{{ step.header }}</pre>
{% else %}
<p class="hint">{{ nav.tr("import-template-nothing-chosen") }}</p>
{% endif %}
<p class="hint">{{ nav.tr("import-template-round-trip") }}</p>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
{# A real submit: the answer is a file. #}
<button type="submit" formaction="/admin/import/template.csv" formnovalidate>{{ nav.tr("import-download-template") }}</button>
</div>
{#- ------------------------------------------------------------------ {#- ------------------------------------------------------------------
Step 3 — the prepared import, exactly as it will be sent. Step 3 — the prepared import, exactly as it will be sent.
------------------------------------------------------------------ -#} ------------------------------------------------------------------ -#}
{% when Step::Preview with (step) %} {% when Step::Preview with (step) %}
{% include "pages/import_export/import/carried.html" %} {% include "pages/import_export/import/carried.html" %}
{% for value in page.form.mapping %}<input type="hidden" name="mapping" value="{{ value }}">{% endfor %} {#
The mapping travels with the preview, so Import and Download prepare the
identical file from the identical answers.
#}
{% for key in page.form.destination %}<input type="hidden" name="destination" value="{{ key }}">{% endfor %}
{% for position in page.form.source_position %}<input type="hidden" name="source_position" value="{{ position }}">{% endfor %}
<div class="builder-section"> <div class="builder-section">
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2> <h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
<dl class="postable-grid"> <dl class="postable-grid">