import improvements2
This commit is contained in:
@@ -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 má { $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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 má { $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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
227
web/src/pages/import_export/import/destination.rs
Normal file
227
web/src/pages/import_export/import/destination.rs
Normal 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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(""name""), "{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(""name","active""), "{html}");
|
assert!(html.contains(""a","b","c""), "{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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}">
|
||||||
|
|||||||
@@ -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 => 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 => 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 %}>↑</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 %}>↓</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user